@realsee/vr-cli 0.1.4 → 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 +46 -29
- package/agent-assets/AGENTS.md +8 -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 +194 -13
- package/templates/base/.env.example +4 -2
- package/templates/base/.gitignore.template +2 -0
- package/templates/base/.prettierignore +2 -0
- package/templates/base/README.md +35 -8
- 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 +6 -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 +252 -0
- package/templates/base/scripts/setup-config.test.mjs +123 -0
- package/templates/base/scripts/setup.mjs +203 -0
- package/templates/base/server/app.ts +3 -1
- package/templates/base/server/config.ts +12 -1
- package/templates/base/server/index.ts +5 -1
- package/templates/base/server/types.ts +2 -1
- package/templates/base/server/work-route.ts +51 -32
- package/templates/base/server/work-service.ts +2 -2
- package/templates/base/src/vr/work/WorkSource.ts +13 -9
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { chmod, lstat, mkdir, open, rename, rm } from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
export const SETUP_PAYLOAD_PREFIX = 'realsee-setup:v1:'
|
|
8
|
+
export const DEFAULT_GATEWAY_ENDPOINT = 'https://app-gateway.realsee.cn'
|
|
9
|
+
|
|
10
|
+
const RESOURCE_CODE_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
|
11
|
+
const GATEWAY_ENDPOINT_PATTERN =
|
|
12
|
+
/^https:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[A-Za-z0-9._~%/-]*)?$/u
|
|
13
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/
|
|
14
|
+
const MAX_PAYLOAD_LENGTH = 8_192
|
|
15
|
+
const MAX_CREDENTIAL_LENGTH = 1_024
|
|
16
|
+
|
|
17
|
+
function setupError(message) {
|
|
18
|
+
return new Error(`配置包无效:${message}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function credentialError(message) {
|
|
22
|
+
return new Error(`网关凭证无效:${message}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function exactString(value, label, makeError = setupError) {
|
|
26
|
+
if (
|
|
27
|
+
typeof value !== 'string' ||
|
|
28
|
+
value.length === 0 ||
|
|
29
|
+
value.length > MAX_CREDENTIAL_LENGTH ||
|
|
30
|
+
value !== value.trim() ||
|
|
31
|
+
/[\0\r\n]/u.test(value)
|
|
32
|
+
) {
|
|
33
|
+
throw makeError(`${label} 缺失或格式不正确。`)
|
|
34
|
+
}
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Normalizes credentials resolved outside the pasted payload (environment
|
|
39
|
+
// variables and the pairing protocol) into the exact shape the writer renders.
|
|
40
|
+
export function normalizeGatewayCredentials({
|
|
41
|
+
appKey,
|
|
42
|
+
appSecret,
|
|
43
|
+
resourceCode,
|
|
44
|
+
gatewayEndpoint,
|
|
45
|
+
}) {
|
|
46
|
+
const config = {
|
|
47
|
+
appKey: exactString(appKey, 'AK', credentialError),
|
|
48
|
+
appSecret: exactString(appSecret, 'SK', credentialError),
|
|
49
|
+
}
|
|
50
|
+
if (resourceCode !== undefined && resourceCode !== '') {
|
|
51
|
+
const value = exactString(resourceCode, 'resourceCode', credentialError)
|
|
52
|
+
if (!RESOURCE_CODE_PATTERN.test(value)) {
|
|
53
|
+
throw credentialError('resourceCode 格式不正确。')
|
|
54
|
+
}
|
|
55
|
+
config.resourceCode = value
|
|
56
|
+
}
|
|
57
|
+
if (gatewayEndpoint !== undefined && gatewayEndpoint !== '') {
|
|
58
|
+
const value = exactString(
|
|
59
|
+
gatewayEndpoint,
|
|
60
|
+
'gatewayEndpoint',
|
|
61
|
+
credentialError,
|
|
62
|
+
)
|
|
63
|
+
if (!GATEWAY_ENDPOINT_PATTERN.test(value)) {
|
|
64
|
+
throw credentialError('gatewayEndpoint 必须是 HTTPS URL。')
|
|
65
|
+
}
|
|
66
|
+
config.gatewayEndpoint = value
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze(config)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function decodeSetupPayload(input) {
|
|
72
|
+
const value = typeof input === 'string' ? input.trim() : ''
|
|
73
|
+
if (!value.startsWith(SETUP_PAYLOAD_PREFIX)) {
|
|
74
|
+
throw setupError('请从 Portal 复制完整的 v1 本地配置。')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const encoded = value.slice(SETUP_PAYLOAD_PREFIX.length)
|
|
78
|
+
if (
|
|
79
|
+
encoded.length === 0 ||
|
|
80
|
+
encoded.length > MAX_PAYLOAD_LENGTH ||
|
|
81
|
+
!BASE64URL_PATTERN.test(encoded)
|
|
82
|
+
) {
|
|
83
|
+
throw setupError('单行内容损坏或不完整。')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let text
|
|
87
|
+
try {
|
|
88
|
+
const bytes = Buffer.from(encoded, 'base64url')
|
|
89
|
+
if (bytes.toString('base64url') !== encoded) {
|
|
90
|
+
throw setupError('Base64URL 内容不是规范编码。')
|
|
91
|
+
}
|
|
92
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
93
|
+
} catch {
|
|
94
|
+
throw setupError('Base64URL 内容无法解码。')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let parsed
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(text)
|
|
100
|
+
} catch {
|
|
101
|
+
throw setupError('JSON 内容无法解析。')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
105
|
+
throw setupError('内容结构不正确。')
|
|
106
|
+
}
|
|
107
|
+
const keys = Object.keys(parsed).sort()
|
|
108
|
+
const keyList = keys.join(',')
|
|
109
|
+
if (
|
|
110
|
+
keyList !== 'appKey,appSecret' &&
|
|
111
|
+
keyList !== 'appKey,appSecret,resourceCode'
|
|
112
|
+
) {
|
|
113
|
+
throw setupError('字段集合不正确。')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const appKey = exactString(parsed.appKey, 'AK')
|
|
117
|
+
const appSecret = exactString(parsed.appSecret, 'SK')
|
|
118
|
+
let resourceCode
|
|
119
|
+
if (parsed.resourceCode !== undefined && parsed.resourceCode !== '') {
|
|
120
|
+
resourceCode = exactString(parsed.resourceCode, 'resourceCode')
|
|
121
|
+
if (!RESOURCE_CODE_PATTERN.test(resourceCode)) {
|
|
122
|
+
throw setupError('resourceCode 格式不正确。')
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
appKey,
|
|
128
|
+
appSecret,
|
|
129
|
+
...(resourceCode === undefined ? {} : { resourceCode }),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function quoteDotenvValue(value) {
|
|
134
|
+
return JSON.stringify(value)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderLocalEnv({
|
|
138
|
+
appKey,
|
|
139
|
+
appSecret,
|
|
140
|
+
resourceCode,
|
|
141
|
+
gatewayEndpoint,
|
|
142
|
+
}) {
|
|
143
|
+
return [
|
|
144
|
+
'# Generated by npm run setup. Keep this server-only file out of version control.',
|
|
145
|
+
`REALSEE_GATEWAY_ENDPOINT=${gatewayEndpoint ?? DEFAULT_GATEWAY_ENDPOINT}`,
|
|
146
|
+
`REALSEE_GATEWAY_APP_KEY=${quoteDotenvValue(appKey)}`,
|
|
147
|
+
`REALSEE_GATEWAY_APP_SECRET=${quoteDotenvValue(appSecret)}`,
|
|
148
|
+
...(resourceCode
|
|
149
|
+
? [`REALSEE_DEFAULT_RESOURCE_CODE=${quoteDotenvValue(resourceCode)}`]
|
|
150
|
+
: []),
|
|
151
|
+
'',
|
|
152
|
+
].join('\n')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function run(command, args) {
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
const child = spawn(command, args, {
|
|
158
|
+
shell: false,
|
|
159
|
+
stdio: 'ignore',
|
|
160
|
+
windowsHide: true,
|
|
161
|
+
})
|
|
162
|
+
child.once('error', reject)
|
|
163
|
+
child.once('exit', (code) => {
|
|
164
|
+
if (code === 0) resolve()
|
|
165
|
+
else reject(new Error(`${command} exited with code ${code ?? 'unknown'}`))
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function windowsIdentity(env) {
|
|
171
|
+
const username = env.USERNAME?.trim() || os.userInfo().username
|
|
172
|
+
const domain = env.USERDOMAIN?.trim()
|
|
173
|
+
return domain ? `${domain}\\${username}` : username
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function protectLocalConfig(
|
|
177
|
+
targetPath,
|
|
178
|
+
{ platform = process.platform, env = process.env, runCommand = run } = {},
|
|
179
|
+
) {
|
|
180
|
+
if (platform === 'win32') {
|
|
181
|
+
const identity = windowsIdentity(env)
|
|
182
|
+
await runCommand('icacls.exe', [
|
|
183
|
+
targetPath,
|
|
184
|
+
'/inheritance:r',
|
|
185
|
+
'/grant:r',
|
|
186
|
+
`${identity}:(F)`,
|
|
187
|
+
])
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
await chmod(targetPath, 0o600)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function assertSafeTarget(targetPath) {
|
|
194
|
+
try {
|
|
195
|
+
const target = await lstat(targetPath)
|
|
196
|
+
if (!target.isFile() || target.isSymbolicLink()) {
|
|
197
|
+
throw new Error('.env.development 必须是普通文件。')
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error?.code !== 'ENOENT') throw error
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Shared atomic write chain: restrictive umask, exclusive 0600 temp file,
|
|
205
|
+
// fsync, platform permission hardening, then rename into place. Used for
|
|
206
|
+
// `.env.development` and for the 0600 pairing continuation file.
|
|
207
|
+
export async function writePrivateFile(
|
|
208
|
+
targetPath,
|
|
209
|
+
content,
|
|
210
|
+
{ platform = process.platform, env = process.env, runCommand = run } = {},
|
|
211
|
+
) {
|
|
212
|
+
await mkdir(path.dirname(targetPath), { recursive: true, mode: 0o700 })
|
|
213
|
+
const temporaryPath = path.join(
|
|
214
|
+
path.dirname(targetPath),
|
|
215
|
+
`.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
216
|
+
)
|
|
217
|
+
const previousUmask = platform === 'win32' ? undefined : process.umask(0o077)
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const handle = await open(temporaryPath, 'wx', 0o600)
|
|
221
|
+
try {
|
|
222
|
+
await handle.writeFile(content, 'utf8')
|
|
223
|
+
await handle.sync()
|
|
224
|
+
} finally {
|
|
225
|
+
await handle.close()
|
|
226
|
+
}
|
|
227
|
+
await protectLocalConfig(temporaryPath, { platform, env, runCommand })
|
|
228
|
+
await rename(temporaryPath, targetPath)
|
|
229
|
+
return targetPath
|
|
230
|
+
} finally {
|
|
231
|
+
if (previousUmask !== undefined) process.umask(previousUmask)
|
|
232
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function writeLocalConfig(
|
|
237
|
+
config,
|
|
238
|
+
{
|
|
239
|
+
root = process.cwd(),
|
|
240
|
+
platform = process.platform,
|
|
241
|
+
env = process.env,
|
|
242
|
+
runCommand = run,
|
|
243
|
+
} = {},
|
|
244
|
+
) {
|
|
245
|
+
const targetPath = path.join(path.resolve(root), '.env.development')
|
|
246
|
+
await assertSafeTarget(targetPath)
|
|
247
|
+
return writePrivateFile(targetPath, renderLocalEnv(config), {
|
|
248
|
+
platform,
|
|
249
|
+
env,
|
|
250
|
+
runCommand,
|
|
251
|
+
})
|
|
252
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import {
|
|
7
|
+
decodeSetupPayload,
|
|
8
|
+
protectLocalConfig,
|
|
9
|
+
renderLocalEnv,
|
|
10
|
+
SETUP_PAYLOAD_PREFIX,
|
|
11
|
+
writeLocalConfig,
|
|
12
|
+
} from './setup-config.mjs'
|
|
13
|
+
|
|
14
|
+
const CONFIG = Object.freeze({
|
|
15
|
+
appKey: 'test-app-key',
|
|
16
|
+
appSecret: '测试-secret_+/=',
|
|
17
|
+
resourceCode: 'resource-code_1',
|
|
18
|
+
})
|
|
19
|
+
const CONFIG_WITHOUT_RESOURCE = Object.freeze({
|
|
20
|
+
appKey: CONFIG.appKey,
|
|
21
|
+
appSecret: CONFIG.appSecret,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const ENCODED =
|
|
25
|
+
'eyJhcHBLZXkiOiJ0ZXN0LWFwcC1rZXkiLCJhcHBTZWNyZXQiOiLmtYvor5Utc2VjcmV0XysvPSIsInJlc291cmNlQ29kZSI6InJlc291cmNlLWNvZGVfMSJ9'
|
|
26
|
+
const encode = (value) =>
|
|
27
|
+
Buffer.from(JSON.stringify(value)).toString('base64url')
|
|
28
|
+
|
|
29
|
+
test('decodes the existing versioned payload with its optional resource fallback', () => {
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
decodeSetupPayload(`${SETUP_PAYLOAD_PREFIX}${ENCODED}`),
|
|
32
|
+
CONFIG,
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('accepts a v1 setup payload without a resource fallback', () => {
|
|
37
|
+
assert.deepEqual(
|
|
38
|
+
decodeSetupPayload(
|
|
39
|
+
`${SETUP_PAYLOAD_PREFIX}${encode(CONFIG_WITHOUT_RESOURCE)}`,
|
|
40
|
+
),
|
|
41
|
+
CONFIG_WITHOUT_RESOURCE,
|
|
42
|
+
)
|
|
43
|
+
assert.deepEqual(
|
|
44
|
+
decodeSetupPayload(
|
|
45
|
+
`${SETUP_PAYLOAD_PREFIX}${encode({ ...CONFIG_WITHOUT_RESOURCE, resourceCode: '' })}`,
|
|
46
|
+
),
|
|
47
|
+
CONFIG_WITHOUT_RESOURCE,
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('rejects non-canonical, malformed and extra-field payloads', () => {
|
|
52
|
+
assert.throws(() => decodeSetupPayload('not-a-setup-payload'))
|
|
53
|
+
assert.throws(() => decodeSetupPayload(`${SETUP_PAYLOAD_PREFIX}${ENCODED}=`))
|
|
54
|
+
assert.throws(() =>
|
|
55
|
+
decodeSetupPayload(
|
|
56
|
+
`${SETUP_PAYLOAD_PREFIX}${encode({ appKey: CONFIG.appKey })}`,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
assert.throws(() =>
|
|
60
|
+
decodeSetupPayload(
|
|
61
|
+
`${SETUP_PAYLOAD_PREFIX}${encode({ ...CONFIG, resourceCode: 'invalid code' })}`,
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
assert.throws(() =>
|
|
65
|
+
decodeSetupPayload(
|
|
66
|
+
`${SETUP_PAYLOAD_PREFIX}${encode({ ...CONFIG, unexpected: true })}`,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('renders the optional fallback only when it is configured', () => {
|
|
72
|
+
const rendered = renderLocalEnv(CONFIG)
|
|
73
|
+
assert.match(
|
|
74
|
+
rendered,
|
|
75
|
+
/REALSEE_GATEWAY_ENDPOINT=https:\/\/app-gateway\.realsee\.cn/u,
|
|
76
|
+
)
|
|
77
|
+
assert.match(rendered, /REALSEE_GATEWAY_APP_KEY="test-app-key"/u)
|
|
78
|
+
assert.match(rendered, /REALSEE_GATEWAY_APP_SECRET="测试-secret_\+\/="/u)
|
|
79
|
+
assert.match(rendered, /REALSEE_DEFAULT_RESOURCE_CODE="resource-code_1"/u)
|
|
80
|
+
assert.doesNotMatch(rendered, /realsee-setup:v1:/u)
|
|
81
|
+
|
|
82
|
+
const withoutResource = renderLocalEnv(CONFIG_WITHOUT_RESOURCE)
|
|
83
|
+
assert.match(withoutResource, /REALSEE_GATEWAY_APP_KEY="test-app-key"/u)
|
|
84
|
+
assert.doesNotMatch(withoutResource, /REALSEE_DEFAULT_RESOURCE_CODE/u)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('writes atomically with restrictive POSIX permissions', async () => {
|
|
88
|
+
if (process.platform === 'win32') return
|
|
89
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'realsee-setup-'))
|
|
90
|
+
try {
|
|
91
|
+
const target = await writeLocalConfig(CONFIG_WITHOUT_RESOURCE, {
|
|
92
|
+
root,
|
|
93
|
+
platform: 'linux',
|
|
94
|
+
})
|
|
95
|
+
assert.equal(
|
|
96
|
+
await readFile(target, 'utf8'),
|
|
97
|
+
renderLocalEnv(CONFIG_WITHOUT_RESOURCE),
|
|
98
|
+
)
|
|
99
|
+
assert.equal((await stat(target)).mode & 0o777, 0o600)
|
|
100
|
+
} finally {
|
|
101
|
+
await rm(root, { recursive: true, force: true })
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('uses icacls without a shell for Windows ACL protection', async () => {
|
|
106
|
+
const calls = []
|
|
107
|
+
await protectLocalConfig('C:\\project\\.env.development', {
|
|
108
|
+
platform: 'win32',
|
|
109
|
+
env: { USERDOMAIN: 'WORKSTATION', USERNAME: 'developer' },
|
|
110
|
+
runCommand: async (command, args) => calls.push({ command, args }),
|
|
111
|
+
})
|
|
112
|
+
assert.deepEqual(calls, [
|
|
113
|
+
{
|
|
114
|
+
command: 'icacls.exe',
|
|
115
|
+
args: [
|
|
116
|
+
'C:\\project\\.env.development',
|
|
117
|
+
'/inheritance:r',
|
|
118
|
+
'/grant:r',
|
|
119
|
+
'WORKSTATION\\developer:(F)',
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
])
|
|
123
|
+
})
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
import { parseArgs } from 'node:util'
|
|
3
|
+
import { Writable } from 'node:stream'
|
|
4
|
+
import { createInterface } from 'node:readline/promises'
|
|
5
|
+
import {
|
|
6
|
+
decodeSetupPayload,
|
|
7
|
+
normalizeGatewayCredentials,
|
|
8
|
+
writeLocalConfig,
|
|
9
|
+
} from './setup-config.mjs'
|
|
10
|
+
import {
|
|
11
|
+
PAIRING_EXIT_CODES,
|
|
12
|
+
maskAppKey,
|
|
13
|
+
reportPairingFailure,
|
|
14
|
+
runPairingCommand,
|
|
15
|
+
runPairingCompleteCommand,
|
|
16
|
+
} from './pairing.mjs'
|
|
17
|
+
|
|
18
|
+
const HELP = `用法:npm run setup [-- <选项>]
|
|
19
|
+
|
|
20
|
+
默认走配对:发起请求,打印验证链接与短码,阻塞轮询到终态并写入 .env.development。
|
|
21
|
+
--pair 显式选择配对路径(默认行为)。
|
|
22
|
+
--no-wait 发起后立即退出(退出码 2),稍后用 --complete 续做。
|
|
23
|
+
--complete 读取 .realsee/pairing-continuation.json,轮询到终态并写入。
|
|
24
|
+
--paste 回退路径:把 Portal 复制的单行配置包粘贴到隐藏输入。
|
|
25
|
+
--json 只输出 JSON 信封,不打印人类可读提示。
|
|
26
|
+
-h, --help 显示本帮助。
|
|
27
|
+
|
|
28
|
+
环境变量 REALSEE_GATEWAY_APP_KEY / REALSEE_GATEWAY_APP_SECRET 优先级最高,
|
|
29
|
+
设置后跳过配对,直接写入 .env.development。
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
class HiddenOutput extends Writable {
|
|
33
|
+
_write(_chunk, _encoding, callback) {
|
|
34
|
+
callback()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readHiddenPayload() {
|
|
39
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'npm run setup 需要交互终端。请勿通过参数、管道、重定向或聊天传入配置包。',
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
process.stdout.write(
|
|
46
|
+
'请在 Portal 点击“复制本地配置”,粘贴到这里并按 Enter(输入不会显示):',
|
|
47
|
+
)
|
|
48
|
+
const controller = new AbortController()
|
|
49
|
+
const input = process.stdin
|
|
50
|
+
const output = new HiddenOutput()
|
|
51
|
+
const prompt = createInterface({ input, output, terminal: true })
|
|
52
|
+
prompt.once('SIGINT', () => controller.abort())
|
|
53
|
+
try {
|
|
54
|
+
const value = await prompt.question('', { signal: controller.signal })
|
|
55
|
+
process.stdout.write('\n')
|
|
56
|
+
return value
|
|
57
|
+
} finally {
|
|
58
|
+
prompt.close()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function runPasteFlow() {
|
|
63
|
+
const payload = decodeSetupPayload(await readHiddenPayload())
|
|
64
|
+
await writeLocalConfig(payload)
|
|
65
|
+
console.log('配置完成。请回到 AI 助手并回复“已完成”。')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseSetupArguments(argv) {
|
|
69
|
+
try {
|
|
70
|
+
return parseArgs({
|
|
71
|
+
args: argv,
|
|
72
|
+
allowPositionals: false,
|
|
73
|
+
strict: true,
|
|
74
|
+
options: {
|
|
75
|
+
pair: { type: 'boolean', default: false },
|
|
76
|
+
paste: { type: 'boolean', default: false },
|
|
77
|
+
complete: { type: 'boolean', default: false },
|
|
78
|
+
'no-wait': { type: 'boolean', default: false },
|
|
79
|
+
json: { type: 'boolean', default: false },
|
|
80
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
81
|
+
},
|
|
82
|
+
}).values
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`参数错误:${error instanceof Error ? error.message : String(error)}`,
|
|
86
|
+
{ cause: error },
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveMode(values) {
|
|
92
|
+
if (values.paste) {
|
|
93
|
+
const conflicting = ['pair', 'complete', 'no-wait', 'json'].find(
|
|
94
|
+
(flag) => values[flag] === true,
|
|
95
|
+
)
|
|
96
|
+
if (conflicting) {
|
|
97
|
+
throw new Error(`--paste 不能与 --${conflicting} 同时使用。`)
|
|
98
|
+
}
|
|
99
|
+
return 'paste'
|
|
100
|
+
}
|
|
101
|
+
if (values.complete) {
|
|
102
|
+
if (values['no-wait']) {
|
|
103
|
+
throw new Error('--complete 不能与 --no-wait 同时使用。')
|
|
104
|
+
}
|
|
105
|
+
return 'complete'
|
|
106
|
+
}
|
|
107
|
+
return 'pair'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function environmentCredentials(env = process.env) {
|
|
111
|
+
const appKey = env.REALSEE_GATEWAY_APP_KEY?.trim()
|
|
112
|
+
const appSecret = env.REALSEE_GATEWAY_APP_SECRET?.trim()
|
|
113
|
+
if (!appKey && !appSecret) return null
|
|
114
|
+
if (!appKey || !appSecret) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'REALSEE_GATEWAY_APP_KEY 与 REALSEE_GATEWAY_APP_SECRET 必须同时提供。',
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
const resourceCode = env.REALSEE_DEFAULT_RESOURCE_CODE?.trim()
|
|
120
|
+
const gatewayEndpoint = env.REALSEE_GATEWAY_ENDPOINT?.trim()
|
|
121
|
+
return normalizeGatewayCredentials({
|
|
122
|
+
appKey,
|
|
123
|
+
appSecret,
|
|
124
|
+
...(resourceCode ? { resourceCode } : {}),
|
|
125
|
+
...(gatewayEndpoint ? { gatewayEndpoint } : {}),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function configureFromEnvironment(config, { json }) {
|
|
130
|
+
await writeLocalConfig(config)
|
|
131
|
+
if (json !== true) {
|
|
132
|
+
process.stderr.write(
|
|
133
|
+
'检测到 REALSEE_GATEWAY_APP_KEY / REALSEE_GATEWAY_APP_SECRET 环境变量(优先级最高),已跳过配对并写入 .env.development。\n',
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write(
|
|
137
|
+
`${JSON.stringify({
|
|
138
|
+
ok: true,
|
|
139
|
+
data: {
|
|
140
|
+
status: 'configured',
|
|
141
|
+
source: 'environment',
|
|
142
|
+
app_key: maskAppKey(config.appKey),
|
|
143
|
+
path: '.env.development',
|
|
144
|
+
},
|
|
145
|
+
})}\n`,
|
|
146
|
+
)
|
|
147
|
+
return PAIRING_EXIT_CODES.OK
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
151
|
+
let values
|
|
152
|
+
try {
|
|
153
|
+
values = parseSetupArguments(argv)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return reportPairingFailure(error, { json: false })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (values.help) {
|
|
159
|
+
process.stdout.write(HELP)
|
|
160
|
+
return PAIRING_EXIT_CODES.OK
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let mode
|
|
164
|
+
try {
|
|
165
|
+
mode = resolveMode(values)
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return reportPairingFailure(error, { json: values.json === true })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The paste fallback keeps its original interactive behaviour.
|
|
171
|
+
if (mode === 'paste') {
|
|
172
|
+
await runPasteFlow()
|
|
173
|
+
return PAIRING_EXIT_CODES.OK
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const json = values.json === true
|
|
177
|
+
try {
|
|
178
|
+
const config = environmentCredentials()
|
|
179
|
+
if (config) return await configureFromEnvironment(config, { json })
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return reportPairingFailure(error, { json })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (mode === 'complete') {
|
|
185
|
+
return await runPairingCompleteCommand({ json })
|
|
186
|
+
}
|
|
187
|
+
return await runPairingCommand({ noWait: values['no-wait'] === true, json })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const entrypoint = process.argv[1]
|
|
191
|
+
if (entrypoint && import.meta.url === pathToFileURL(entrypoint).href) {
|
|
192
|
+
main()
|
|
193
|
+
.then((exitCode) => {
|
|
194
|
+
if (typeof exitCode === 'number' && exitCode !== 0) {
|
|
195
|
+
process.exitCode = exitCode
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
.catch((error) => {
|
|
199
|
+
const message = error instanceof Error ? error.message : '未知错误。'
|
|
200
|
+
console.error(`本地配置失败:${message}`)
|
|
201
|
+
process.exitCode = 1
|
|
202
|
+
})
|
|
203
|
+
}
|
|
@@ -9,6 +9,7 @@ const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
|
9
9
|
|
|
10
10
|
export interface CreateAppOptions {
|
|
11
11
|
readonly workService: WorkService
|
|
12
|
+
readonly defaultResourceCode: string | undefined
|
|
12
13
|
readonly logger?: boolean
|
|
13
14
|
readonly serveFrontend?: boolean
|
|
14
15
|
readonly frontendRoot?: string
|
|
@@ -16,6 +17,7 @@ export interface CreateAppOptions {
|
|
|
16
17
|
|
|
17
18
|
export async function createApp({
|
|
18
19
|
workService,
|
|
20
|
+
defaultResourceCode,
|
|
19
21
|
logger = true,
|
|
20
22
|
serveFrontend = false,
|
|
21
23
|
frontendRoot = path.join(PROJECT_ROOT, 'dist'),
|
|
@@ -23,7 +25,7 @@ export async function createApp({
|
|
|
23
25
|
const app = Fastify({ logger })
|
|
24
26
|
|
|
25
27
|
app.get('/api/health', () => ({ ok: true }))
|
|
26
|
-
await app.register(workRoutes, { workService })
|
|
28
|
+
await app.register(workRoutes, { workService, defaultResourceCode })
|
|
27
29
|
|
|
28
30
|
if (serveFrontend) {
|
|
29
31
|
await app.register(fastifyStatic, {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ServerConfig } from './types.ts'
|
|
2
2
|
|
|
3
3
|
const DEFAULT_GATEWAY_ENDPOINT = 'https://app-gateway.realsee.cn'
|
|
4
|
+
const RESOURCE_CODE_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
|
4
5
|
|
|
5
6
|
type ServerEnv = Record<string, string | undefined>
|
|
6
7
|
|
|
@@ -15,8 +16,17 @@ const nonEmptyString = (value: string | undefined): value is string =>
|
|
|
15
16
|
function requiredCredential(env: ServerEnv, key: string): string {
|
|
16
17
|
const value = env[key]?.trim()
|
|
17
18
|
if (!nonEmptyString(value) || value === '*') {
|
|
19
|
+
throw new Error(`${key} 未配置。请运行 npm run setup 完成本地配置。`)
|
|
20
|
+
}
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function optionalResourceCode(env: ServerEnv): string | undefined {
|
|
25
|
+
const value = env.REALSEE_DEFAULT_RESOURCE_CODE?.trim()
|
|
26
|
+
if (!nonEmptyString(value)) return undefined
|
|
27
|
+
if (!RESOURCE_CODE_PATTERN.test(value)) {
|
|
18
28
|
throw new Error(
|
|
19
|
-
|
|
29
|
+
'REALSEE_DEFAULT_RESOURCE_CODE 格式无效,必须匹配 ^[A-Za-z0-9_-]{1,128}$。',
|
|
20
30
|
)
|
|
21
31
|
}
|
|
22
32
|
return value
|
|
@@ -50,5 +60,6 @@ export function loadServerConfig({
|
|
|
50
60
|
gatewayEndpoint: gatewayEndpoint.href.replace(/\/$/, ''),
|
|
51
61
|
appKey: requiredCredential(env, 'REALSEE_GATEWAY_APP_KEY'),
|
|
52
62
|
appSecret: requiredCredential(env, 'REALSEE_GATEWAY_APP_SECRET'),
|
|
63
|
+
defaultResourceCode: optionalResourceCode(env),
|
|
53
64
|
})
|
|
54
65
|
}
|
|
@@ -7,7 +7,11 @@ const serveFrontend = process.argv.includes('--serve-frontend')
|
|
|
7
7
|
const config = loadServerConfig({ serveFrontend })
|
|
8
8
|
const openApiClient = new RealseeOpenApiClient(config)
|
|
9
9
|
const workService = createWorkService({ openApiClient })
|
|
10
|
-
const app = await createApp({
|
|
10
|
+
const app = await createApp({
|
|
11
|
+
workService,
|
|
12
|
+
defaultResourceCode: config.defaultResourceCode,
|
|
13
|
+
serveFrontend,
|
|
14
|
+
})
|
|
11
15
|
|
|
12
16
|
let closing = false
|
|
13
17
|
const close = async (signal: string): Promise<void> => {
|
|
@@ -11,6 +11,7 @@ export interface ServerConfig {
|
|
|
11
11
|
readonly gatewayEndpoint: string
|
|
12
12
|
readonly appKey: string
|
|
13
13
|
readonly appSecret: string
|
|
14
|
+
readonly defaultResourceCode: string | undefined
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface RequestOptions {
|
|
@@ -21,7 +22,7 @@ export interface WorkCapabilities {
|
|
|
21
22
|
readonly modelFloorplan?: ModelFloorplanServerData
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/** Browser-safe response envelope for `GET /api/work
|
|
25
|
+
/** Browser-safe response envelope for `GET /api/work`. */
|
|
25
26
|
export interface WorkEnvelope {
|
|
26
27
|
readonly work: RawRecord
|
|
27
28
|
readonly workCode: string
|