@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,227 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
mkdir,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
readFile,
|
|
7
|
+
rm,
|
|
8
|
+
stat,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from 'node:fs/promises'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import test from 'node:test'
|
|
14
|
+
import {
|
|
15
|
+
CONTINUATION_RELATIVE_PATH,
|
|
16
|
+
readPairingContinuation,
|
|
17
|
+
runPairingCommand,
|
|
18
|
+
runPairingCompleteCommand,
|
|
19
|
+
} from './pairing.mjs'
|
|
20
|
+
|
|
21
|
+
const PORTAL_BASE_URL = 'http://127.0.0.1:43123'
|
|
22
|
+
const APP_KEY = 'test-app-key-1234'
|
|
23
|
+
const APP_SECRET = 'test-app-secret-value'
|
|
24
|
+
const ENV_KEYS = [
|
|
25
|
+
'REALSEE_PORTAL_BASE_URL',
|
|
26
|
+
'REALSEE_GATEWAY_APP_KEY',
|
|
27
|
+
'REALSEE_GATEWAY_APP_SECRET',
|
|
28
|
+
'REALSEE_DEFAULT_RESOURCE_CODE',
|
|
29
|
+
'REALSEE_GATEWAY_ENDPOINT',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
async function temporaryProject() {
|
|
33
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'realsee-pairing-'))
|
|
34
|
+
await writeFile(
|
|
35
|
+
path.join(root, 'package.json'),
|
|
36
|
+
JSON.stringify({ name: 'pairing-test-app' }),
|
|
37
|
+
)
|
|
38
|
+
return root
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function prepareProcess(t, root) {
|
|
42
|
+
const previousDirectory = process.cwd()
|
|
43
|
+
const previousEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]]))
|
|
44
|
+
process.chdir(root)
|
|
45
|
+
for (const key of ENV_KEYS) delete process.env[key]
|
|
46
|
+
process.env.REALSEE_PORTAL_BASE_URL = PORTAL_BASE_URL
|
|
47
|
+
|
|
48
|
+
let stdout = ''
|
|
49
|
+
let stderr = ''
|
|
50
|
+
t.mock.method(process.stdout, 'write', (chunk) => {
|
|
51
|
+
stdout += String(chunk)
|
|
52
|
+
return true
|
|
53
|
+
})
|
|
54
|
+
t.mock.method(process.stderr, 'write', (chunk) => {
|
|
55
|
+
stderr += String(chunk)
|
|
56
|
+
return true
|
|
57
|
+
})
|
|
58
|
+
t.after(() => {
|
|
59
|
+
process.chdir(previousDirectory)
|
|
60
|
+
for (const [key, value] of previousEnv) {
|
|
61
|
+
if (value === undefined) delete process.env[key]
|
|
62
|
+
else process.env[key] = value
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
return {
|
|
66
|
+
stdout: () => stdout,
|
|
67
|
+
stderr: () => stderr,
|
|
68
|
+
clearOutput: () => {
|
|
69
|
+
stdout = ''
|
|
70
|
+
stderr = ''
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function jsonResponse(value) {
|
|
76
|
+
return new Response(JSON.stringify(value), {
|
|
77
|
+
status: 200,
|
|
78
|
+
headers: { 'content-type': 'application/json' },
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function installPortalFetch(t) {
|
|
83
|
+
const observed = { starts: [], polls: 0 }
|
|
84
|
+
let pollResult = 'denied'
|
|
85
|
+
t.mock.method(globalThis, 'fetch', async (input, options = {}) => {
|
|
86
|
+
const url = new URL(input)
|
|
87
|
+
if (url.pathname === '/api/cli/pairing/start') {
|
|
88
|
+
observed.starts.push(JSON.parse(options.body))
|
|
89
|
+
return jsonResponse({
|
|
90
|
+
ok: true,
|
|
91
|
+
data: {
|
|
92
|
+
user_code: 'GPVB-SWBF',
|
|
93
|
+
verification_uri_complete: `${PORTAL_BASE_URL}/spatial-apps/create/authorize?user_code=GPVB-SWBF`,
|
|
94
|
+
poll_secret: 'test-poll-secret',
|
|
95
|
+
interval: 5,
|
|
96
|
+
expires_in: 300,
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
if (url.pathname === '/api/cli/pairing/poll') {
|
|
101
|
+
observed.polls += 1
|
|
102
|
+
assert.equal(options.headers.authorization, 'Bearer test-poll-secret')
|
|
103
|
+
if (pollResult === 'approved') {
|
|
104
|
+
return jsonResponse({
|
|
105
|
+
ok: true,
|
|
106
|
+
data: {
|
|
107
|
+
app_key: APP_KEY,
|
|
108
|
+
app_secret: APP_SECRET,
|
|
109
|
+
resource_code: 'resource-code-1',
|
|
110
|
+
gateway_endpoint: 'https://app-gateway.realsee.cn',
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
return jsonResponse({ ok: false, error: { code: 'access_denied' } })
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`Unexpected pairing request: ${url.pathname}`)
|
|
117
|
+
})
|
|
118
|
+
return {
|
|
119
|
+
observed,
|
|
120
|
+
approve: () => {
|
|
121
|
+
pollResult = 'approved'
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
test('completes the split pairing flow without printing credentials', async (t) => {
|
|
127
|
+
const root = await temporaryProject()
|
|
128
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
129
|
+
const output = prepareProcess(t, root)
|
|
130
|
+
const portal = installPortalFetch(t)
|
|
131
|
+
|
|
132
|
+
const started = await runPairingCommand({ noWait: true, json: true })
|
|
133
|
+
assert.equal(started, 2)
|
|
134
|
+
assert.equal(output.stderr(), '')
|
|
135
|
+
const pending = JSON.parse(output.stdout())
|
|
136
|
+
assert.equal(pending.ok, true)
|
|
137
|
+
assert.equal(pending.data.status, 'pending')
|
|
138
|
+
assert.equal(pending.data.user_code, 'GPVB-SWBF')
|
|
139
|
+
assert.match(pending.data.client_name, /^pairing-test-app .*#[0-9A-F]{8}$/u)
|
|
140
|
+
assert.doesNotMatch(output.stdout(), /test-poll-secret|test-app-secret/u)
|
|
141
|
+
assert.equal(portal.observed.starts.length, 1)
|
|
142
|
+
assert.equal(portal.observed.starts[0].environment, 'production')
|
|
143
|
+
assert.deepEqual(portal.observed.starts[0].capabilities, ['poll'])
|
|
144
|
+
|
|
145
|
+
const continuation = path.join(root, CONTINUATION_RELATIVE_PATH)
|
|
146
|
+
assert.equal((await stat(continuation)).mode & 0o777, 0o600)
|
|
147
|
+
assert.match(await readFile(continuation, 'utf8'), /test-poll-secret/u)
|
|
148
|
+
|
|
149
|
+
output.clearOutput()
|
|
150
|
+
portal.approve()
|
|
151
|
+
const completed = await runPairingCompleteCommand({ json: true })
|
|
152
|
+
assert.equal(completed, 0)
|
|
153
|
+
assert.equal(output.stderr(), '')
|
|
154
|
+
assert.deepEqual(JSON.parse(output.stdout()), {
|
|
155
|
+
ok: true,
|
|
156
|
+
data: {
|
|
157
|
+
status: 'paired',
|
|
158
|
+
app_key: 'tes****1234',
|
|
159
|
+
path: '.env.development',
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
assert.doesNotMatch(output.stdout(), /test-poll-secret|test-app-secret/u)
|
|
163
|
+
assert.equal(portal.observed.polls, 1)
|
|
164
|
+
|
|
165
|
+
const localConfig = path.join(root, '.env.development')
|
|
166
|
+
assert.equal((await stat(localConfig)).mode & 0o777, 0o600)
|
|
167
|
+
const config = await readFile(localConfig, 'utf8')
|
|
168
|
+
assert.match(config, /REALSEE_GATEWAY_APP_KEY="test-app-key-1234"/u)
|
|
169
|
+
assert.match(config, /REALSEE_GATEWAY_APP_SECRET="test-app-secret-value"/u)
|
|
170
|
+
assert.match(config, /REALSEE_DEFAULT_RESOURCE_CODE="resource-code-1"/u)
|
|
171
|
+
await assert.rejects(stat(continuation), { code: 'ENOENT' })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('a denied pairing preserves the existing local configuration', async (t) => {
|
|
175
|
+
const root = await temporaryProject()
|
|
176
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
177
|
+
const output = prepareProcess(t, root)
|
|
178
|
+
installPortalFetch(t)
|
|
179
|
+
const localConfig = path.join(root, '.env.development')
|
|
180
|
+
const existing = 'existing-local-config\n'
|
|
181
|
+
await writeFile(localConfig, existing)
|
|
182
|
+
if (process.platform !== 'win32') await chmod(localConfig, 0o600)
|
|
183
|
+
|
|
184
|
+
assert.equal(await runPairingCommand({ noWait: true, json: true }), 2)
|
|
185
|
+
output.clearOutput()
|
|
186
|
+
assert.equal(await runPairingCompleteCommand({ json: true }), 3)
|
|
187
|
+
assert.equal(output.stdout(), '')
|
|
188
|
+
assert.deepEqual(JSON.parse(output.stderr()), {
|
|
189
|
+
ok: false,
|
|
190
|
+
error: {
|
|
191
|
+
code: 'access_denied',
|
|
192
|
+
message: '用户在 Portal 拒绝了本次配对请求。',
|
|
193
|
+
hint: '请勿自动重发;请与用户确认原因后重新运行 npm run setup。',
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
assert.equal(await readFile(localConfig, 'utf8'), existing)
|
|
197
|
+
await assert.rejects(stat(path.join(root, CONTINUATION_RELATIVE_PATH)), {
|
|
198
|
+
code: 'ENOENT',
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('rejects unsupported continuation schema versions', async () => {
|
|
203
|
+
const root = await temporaryProject()
|
|
204
|
+
try {
|
|
205
|
+
const directory = path.join(root, '.realsee')
|
|
206
|
+
await mkdir(directory, { mode: 0o700 })
|
|
207
|
+
await writeFile(
|
|
208
|
+
path.join(root, CONTINUATION_RELATIVE_PATH),
|
|
209
|
+
JSON.stringify({
|
|
210
|
+
schemaVersion: 999,
|
|
211
|
+
portalBaseUrl: 'https://open.realsee.cn',
|
|
212
|
+
userCode: 'GPVB-SWBF',
|
|
213
|
+
verificationUriComplete:
|
|
214
|
+
'https://open.realsee.cn/spatial-apps/create/authorize?user_code=GPVB-SWBF',
|
|
215
|
+
pollSecret: 'test-poll-secret',
|
|
216
|
+
intervalSeconds: 5,
|
|
217
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
218
|
+
}),
|
|
219
|
+
)
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
readPairingContinuation({ root }),
|
|
222
|
+
/不支持的 schemaVersion/u,
|
|
223
|
+
)
|
|
224
|
+
} finally {
|
|
225
|
+
await rm(root, { recursive: true, force: true })
|
|
226
|
+
}
|
|
227
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { lstat, mkdir, open, readFile } from 'node:fs/promises'
|
|
3
|
+
import { hostname, userInfo } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
export const PROJECT_IDENTITY_RELATIVE_PATH = '.realsee/project.json'
|
|
7
|
+
// The integrated-commercial-cn pairing create API rejects client_name > 64 chars.
|
|
8
|
+
export const MAX_PAIRING_CLIENT_NAME_LENGTH = 64
|
|
9
|
+
|
|
10
|
+
const UUID_PATTERN =
|
|
11
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
|
|
12
|
+
|
|
13
|
+
function safeString(read) {
|
|
14
|
+
try {
|
|
15
|
+
const value = read()
|
|
16
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
17
|
+
} catch {
|
|
18
|
+
return ''
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function displayPart(value, fallback) {
|
|
23
|
+
const cleaned = (typeof value === 'string' ? value : '')
|
|
24
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, '')
|
|
25
|
+
.replace(/[·#]/gu, ' ')
|
|
26
|
+
.replace(/\s+/gu, ' ')
|
|
27
|
+
.trim()
|
|
28
|
+
return cleaned || fallback
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readSavedIdentity(identityPath) {
|
|
32
|
+
const entry = await lstat(identityPath)
|
|
33
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
34
|
+
throw new Error(`${PROJECT_IDENTITY_RELATIVE_PATH} 必须是普通文件。`)
|
|
35
|
+
}
|
|
36
|
+
let identity
|
|
37
|
+
try {
|
|
38
|
+
identity = JSON.parse(await readFile(identityPath, 'utf8'))
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${PROJECT_IDENTITY_RELATIVE_PATH} 内容无效,请检查后再试。`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
const id = identity?.project_instance_id
|
|
45
|
+
if (
|
|
46
|
+
!identity ||
|
|
47
|
+
typeof identity !== 'object' ||
|
|
48
|
+
Array.isArray(identity) ||
|
|
49
|
+
Object.keys(identity).length !== 1 ||
|
|
50
|
+
typeof id !== 'string' ||
|
|
51
|
+
!UUID_PATTERN.test(id)
|
|
52
|
+
) {
|
|
53
|
+
throw new Error(`${PROJECT_IDENTITY_RELATIVE_PATH} 缺少有效的项目实例 ID。`)
|
|
54
|
+
}
|
|
55
|
+
return id.toLowerCase()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getOrCreateProjectInstanceId(root = process.cwd()) {
|
|
59
|
+
const projectRoot = path.resolve(root)
|
|
60
|
+
const directory = path.join(projectRoot, '.realsee')
|
|
61
|
+
const identityPath = path.join(directory, 'project.json')
|
|
62
|
+
try {
|
|
63
|
+
await mkdir(directory, { mode: 0o700 })
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error?.code !== 'EEXIST') throw error
|
|
66
|
+
}
|
|
67
|
+
const directoryEntry = await lstat(directory)
|
|
68
|
+
if (!directoryEntry.isDirectory() || directoryEntry.isSymbolicLink()) {
|
|
69
|
+
throw new Error('.realsee 必须是当前项目内的普通目录。')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const id = randomUUID()
|
|
73
|
+
let file
|
|
74
|
+
try {
|
|
75
|
+
file = await open(identityPath, 'wx', 0o600)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error?.code !== 'EEXIST') throw error
|
|
78
|
+
// A concurrent setup may have created the file but not finished writing it.
|
|
79
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
80
|
+
try {
|
|
81
|
+
return await readSavedIdentity(identityPath)
|
|
82
|
+
} catch (readError) {
|
|
83
|
+
if (attempt === 9 || !String(readError?.message).includes('内容无效')) {
|
|
84
|
+
throw readError
|
|
85
|
+
}
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await file.writeFile(
|
|
92
|
+
`${JSON.stringify({ project_instance_id: id }, null, 2)}\n`,
|
|
93
|
+
'utf8',
|
|
94
|
+
)
|
|
95
|
+
await file.sync()
|
|
96
|
+
} finally {
|
|
97
|
+
await file.close()
|
|
98
|
+
}
|
|
99
|
+
return id
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readProjectName(root = process.cwd()) {
|
|
103
|
+
const projectRoot = path.resolve(root)
|
|
104
|
+
try {
|
|
105
|
+
const manifest = JSON.parse(
|
|
106
|
+
await readFile(path.join(projectRoot, 'package.json'), 'utf8'),
|
|
107
|
+
)
|
|
108
|
+
if (typeof manifest?.name === 'string') {
|
|
109
|
+
return displayPart(
|
|
110
|
+
manifest.name,
|
|
111
|
+
displayPart(path.basename(projectRoot), 'VR App'),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// An absent or invalid manifest must not turn a non-sensitive display label
|
|
116
|
+
// into a blocker for an otherwise valid pairing request.
|
|
117
|
+
}
|
|
118
|
+
return displayPart(path.basename(projectRoot), 'VR App')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formatPairingClientName({
|
|
122
|
+
projectName,
|
|
123
|
+
projectInstanceId,
|
|
124
|
+
user,
|
|
125
|
+
machine,
|
|
126
|
+
}) {
|
|
127
|
+
if (!UUID_PATTERN.test(projectInstanceId)) {
|
|
128
|
+
throw new Error('项目实例 ID 无效。')
|
|
129
|
+
}
|
|
130
|
+
const shortId = projectInstanceId.slice(0, 8).toUpperCase()
|
|
131
|
+
const suffix = ` · #${shortId}`
|
|
132
|
+
const available = MAX_PAIRING_CLIENT_NAME_LENGTH - suffix.length
|
|
133
|
+
const environment = `${displayPart(user, 'unknown')}@${displayPart(machine, 'unknown')}`
|
|
134
|
+
const limitedEnvironment = environment.slice(0, Math.min(32, available - 5))
|
|
135
|
+
const projectBudget = available - limitedEnvironment.length - 3
|
|
136
|
+
const project = displayPart(projectName, 'VR App').slice(0, projectBudget)
|
|
137
|
+
return `${project} · ${limitedEnvironment}${suffix}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function currentProjectClientName(
|
|
141
|
+
root = process.cwd(),
|
|
142
|
+
env = process.env,
|
|
143
|
+
) {
|
|
144
|
+
const [projectName, projectInstanceId] = await Promise.all([
|
|
145
|
+
readProjectName(root),
|
|
146
|
+
getOrCreateProjectInstanceId(root),
|
|
147
|
+
])
|
|
148
|
+
const user =
|
|
149
|
+
safeString(() => userInfo().username) ||
|
|
150
|
+
env.USER?.trim() ||
|
|
151
|
+
env.USERNAME?.trim() ||
|
|
152
|
+
'unknown'
|
|
153
|
+
const machine = safeString(hostname) || 'unknown'
|
|
154
|
+
return formatPairingClientName({
|
|
155
|
+
projectName,
|
|
156
|
+
projectInstanceId,
|
|
157
|
+
user,
|
|
158
|
+
machine,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import {
|
|
3
|
+
mkdtemp,
|
|
4
|
+
readFile,
|
|
5
|
+
rm,
|
|
6
|
+
stat,
|
|
7
|
+
symlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from 'node:fs/promises'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import test from 'node:test'
|
|
13
|
+
import {
|
|
14
|
+
currentProjectClientName,
|
|
15
|
+
formatPairingClientName,
|
|
16
|
+
getOrCreateProjectInstanceId,
|
|
17
|
+
MAX_PAIRING_CLIENT_NAME_LENGTH,
|
|
18
|
+
readProjectName,
|
|
19
|
+
} from './project-identity.mjs'
|
|
20
|
+
|
|
21
|
+
async function temporaryProject(name = 'my-vr-app') {
|
|
22
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'realsee-project-id-'))
|
|
23
|
+
await writeFile(path.join(root, 'package.json'), JSON.stringify({ name }))
|
|
24
|
+
return root
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('keeps one local project ID across repeated pairing attempts', async () => {
|
|
28
|
+
const root = await temporaryProject()
|
|
29
|
+
try {
|
|
30
|
+
const first = await getOrCreateProjectInstanceId(root)
|
|
31
|
+
const second = await getOrCreateProjectInstanceId(root)
|
|
32
|
+
assert.equal(second, first)
|
|
33
|
+
assert.match(first, /^[0-9a-f]{8}-[0-9a-f-]{27}$/u)
|
|
34
|
+
const identityPath = path.join(root, '.realsee', 'project.json')
|
|
35
|
+
assert.deepEqual(JSON.parse(await readFile(identityPath, 'utf8')), {
|
|
36
|
+
project_instance_id: first,
|
|
37
|
+
})
|
|
38
|
+
if (process.platform !== 'win32') {
|
|
39
|
+
assert.equal((await stat(identityPath)).mode & 0o777, 0o600)
|
|
40
|
+
}
|
|
41
|
+
assert.match(
|
|
42
|
+
await currentProjectClientName(root),
|
|
43
|
+
/my-vr-app.*#[0-9A-F]{8}$/u,
|
|
44
|
+
)
|
|
45
|
+
} finally {
|
|
46
|
+
await rm(root, { recursive: true, force: true })
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('distinguishes same-name projects without sharing local configuration', async () => {
|
|
51
|
+
const firstRoot = await temporaryProject('same-name')
|
|
52
|
+
const secondRoot = await temporaryProject('same-name')
|
|
53
|
+
try {
|
|
54
|
+
const firstId = await getOrCreateProjectInstanceId(firstRoot)
|
|
55
|
+
const secondId = await getOrCreateProjectInstanceId(secondRoot)
|
|
56
|
+
assert.notEqual(firstId, secondId)
|
|
57
|
+
assert.notEqual(
|
|
58
|
+
formatPairingClientName({
|
|
59
|
+
projectName: 'same-name',
|
|
60
|
+
projectInstanceId: firstId,
|
|
61
|
+
user: 'a',
|
|
62
|
+
machine: 'm',
|
|
63
|
+
}),
|
|
64
|
+
formatPairingClientName({
|
|
65
|
+
projectName: 'same-name',
|
|
66
|
+
projectInstanceId: secondId,
|
|
67
|
+
user: 'a',
|
|
68
|
+
machine: 'm',
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
} finally {
|
|
72
|
+
await rm(firstRoot, { recursive: true, force: true })
|
|
73
|
+
await rm(secondRoot, { recursive: true, force: true })
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('preserves the short ID while cleaning and bounding the display name', () => {
|
|
78
|
+
assert.equal(MAX_PAIRING_CLIENT_NAME_LENGTH, 64)
|
|
79
|
+
const id = '4ec413e0-a832-4c43-8958-7353bdf38df1'
|
|
80
|
+
const name = formatPairingClientName({
|
|
81
|
+
projectName: `${'长'.repeat(120)}\n·#`,
|
|
82
|
+
projectInstanceId: id,
|
|
83
|
+
user: 'user\u001b',
|
|
84
|
+
machine: 'machine'.repeat(20),
|
|
85
|
+
})
|
|
86
|
+
assert.ok(name.length <= MAX_PAIRING_CLIENT_NAME_LENGTH)
|
|
87
|
+
assert.match(name, / · #4EC413E0$/u)
|
|
88
|
+
assert.ok(!name.includes('\r'))
|
|
89
|
+
assert.ok(!name.includes('\n'))
|
|
90
|
+
assert.ok(!name.includes('\u001b'))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('uses only the directory name when package metadata is unavailable', async () => {
|
|
94
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'realsee-project-id-'))
|
|
95
|
+
try {
|
|
96
|
+
assert.equal(await readProjectName(root), path.basename(root))
|
|
97
|
+
await writeFile(path.join(root, 'package.json'), '{invalid')
|
|
98
|
+
assert.equal(await readProjectName(root), path.basename(root))
|
|
99
|
+
} finally {
|
|
100
|
+
await rm(root, { recursive: true, force: true })
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('rejects invalid or symlinked saved identities instead of replacing them', async () => {
|
|
105
|
+
const root = await temporaryProject()
|
|
106
|
+
try {
|
|
107
|
+
await getOrCreateProjectInstanceId(root)
|
|
108
|
+
const identityPath = path.join(root, '.realsee', 'project.json')
|
|
109
|
+
await writeFile(identityPath, '{invalid')
|
|
110
|
+
await assert.rejects(getOrCreateProjectInstanceId(root), /内容无效/u)
|
|
111
|
+
if (process.platform !== 'win32') {
|
|
112
|
+
await rm(identityPath)
|
|
113
|
+
await symlink(path.join(root, 'package.json'), identityPath)
|
|
114
|
+
await assert.rejects(getOrCreateProjectInstanceId(root), /普通文件/u)
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
await rm(root, { recursive: true, force: true })
|
|
118
|
+
}
|
|
119
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { chmod, lstat, open, rename, rm } from 'node:fs/promises'
|
|
2
|
+
import { chmod, lstat, mkdir, open, rename, rm } from 'node:fs/promises'
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
@@ -8,6 +8,8 @@ export const SETUP_PAYLOAD_PREFIX = 'realsee-setup:v1:'
|
|
|
8
8
|
export const DEFAULT_GATEWAY_ENDPOINT = 'https://app-gateway.realsee.cn'
|
|
9
9
|
|
|
10
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
|
|
11
13
|
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/
|
|
12
14
|
const MAX_PAYLOAD_LENGTH = 8_192
|
|
13
15
|
const MAX_CREDENTIAL_LENGTH = 1_024
|
|
@@ -16,7 +18,11 @@ function setupError(message) {
|
|
|
16
18
|
return new Error(`配置包无效:${message}`)
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
function
|
|
21
|
+
function credentialError(message) {
|
|
22
|
+
return new Error(`网关凭证无效:${message}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function exactString(value, label, makeError = setupError) {
|
|
20
26
|
if (
|
|
21
27
|
typeof value !== 'string' ||
|
|
22
28
|
value.length === 0 ||
|
|
@@ -24,11 +30,44 @@ function exactString(value, label) {
|
|
|
24
30
|
value !== value.trim() ||
|
|
25
31
|
/[\0\r\n]/u.test(value)
|
|
26
32
|
) {
|
|
27
|
-
throw
|
|
33
|
+
throw makeError(`${label} 缺失或格式不正确。`)
|
|
28
34
|
}
|
|
29
35
|
return value
|
|
30
36
|
}
|
|
31
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
|
+
|
|
32
71
|
export function decodeSetupPayload(input) {
|
|
33
72
|
const value = typeof input === 'string' ? input.trim() : ''
|
|
34
73
|
if (!value.startsWith(SETUP_PAYLOAD_PREFIX)) {
|
|
@@ -66,31 +105,49 @@ export function decodeSetupPayload(input) {
|
|
|
66
105
|
throw setupError('内容结构不正确。')
|
|
67
106
|
}
|
|
68
107
|
const keys = Object.keys(parsed).sort()
|
|
69
|
-
|
|
108
|
+
const keyList = keys.join(',')
|
|
109
|
+
if (
|
|
110
|
+
keyList !== 'appKey,appSecret' &&
|
|
111
|
+
keyList !== 'appKey,appSecret,resourceCode'
|
|
112
|
+
) {
|
|
70
113
|
throw setupError('字段集合不正确。')
|
|
71
114
|
}
|
|
72
115
|
|
|
73
116
|
const appKey = exactString(parsed.appKey, 'AK')
|
|
74
117
|
const appSecret = exactString(parsed.appSecret, 'SK')
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
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
|
+
}
|
|
78
124
|
}
|
|
79
125
|
|
|
80
|
-
return Object.freeze({
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
appKey,
|
|
128
|
+
appSecret,
|
|
129
|
+
...(resourceCode === undefined ? {} : { resourceCode }),
|
|
130
|
+
})
|
|
81
131
|
}
|
|
82
132
|
|
|
83
133
|
function quoteDotenvValue(value) {
|
|
84
134
|
return JSON.stringify(value)
|
|
85
135
|
}
|
|
86
136
|
|
|
87
|
-
export function renderLocalEnv({
|
|
137
|
+
export function renderLocalEnv({
|
|
138
|
+
appKey,
|
|
139
|
+
appSecret,
|
|
140
|
+
resourceCode,
|
|
141
|
+
gatewayEndpoint,
|
|
142
|
+
}) {
|
|
88
143
|
return [
|
|
89
144
|
'# Generated by npm run setup. Keep this server-only file out of version control.',
|
|
90
|
-
`REALSEE_GATEWAY_ENDPOINT=${DEFAULT_GATEWAY_ENDPOINT}`,
|
|
145
|
+
`REALSEE_GATEWAY_ENDPOINT=${gatewayEndpoint ?? DEFAULT_GATEWAY_ENDPOINT}`,
|
|
91
146
|
`REALSEE_GATEWAY_APP_KEY=${quoteDotenvValue(appKey)}`,
|
|
92
147
|
`REALSEE_GATEWAY_APP_SECRET=${quoteDotenvValue(appSecret)}`,
|
|
93
|
-
|
|
148
|
+
...(resourceCode
|
|
149
|
+
? [`REALSEE_DEFAULT_RESOURCE_CODE=${quoteDotenvValue(resourceCode)}`]
|
|
150
|
+
: []),
|
|
94
151
|
'',
|
|
95
152
|
].join('\n')
|
|
96
153
|
}
|
|
@@ -144,27 +201,25 @@ async function assertSafeTarget(targetPath) {
|
|
|
144
201
|
}
|
|
145
202
|
}
|
|
146
203
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
} = {},
|
|
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 } = {},
|
|
155
211
|
) {
|
|
156
|
-
|
|
157
|
-
await assertSafeTarget(targetPath)
|
|
212
|
+
await mkdir(path.dirname(targetPath), { recursive: true, mode: 0o700 })
|
|
158
213
|
const temporaryPath = path.join(
|
|
159
214
|
path.dirname(targetPath),
|
|
160
|
-
|
|
215
|
+
`.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
161
216
|
)
|
|
162
217
|
const previousUmask = platform === 'win32' ? undefined : process.umask(0o077)
|
|
163
218
|
|
|
164
219
|
try {
|
|
165
220
|
const handle = await open(temporaryPath, 'wx', 0o600)
|
|
166
221
|
try {
|
|
167
|
-
await handle.writeFile(
|
|
222
|
+
await handle.writeFile(content, 'utf8')
|
|
168
223
|
await handle.sync()
|
|
169
224
|
} finally {
|
|
170
225
|
await handle.close()
|
|
@@ -177,3 +232,21 @@ export async function writeLocalConfig(
|
|
|
177
232
|
await rm(temporaryPath, { force: true }).catch(() => undefined)
|
|
178
233
|
}
|
|
179
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
|
+
}
|