@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,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 exactString(value, label) {
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 setupError(`${label} 缺失或格式不正确。`)
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
- if (keys.join(',') !== 'appKey,appSecret,resourceCode') {
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
- const resourceCode = exactString(parsed.resourceCode, 'resourceCode')
76
- if (!RESOURCE_CODE_PATTERN.test(resourceCode)) {
77
- throw setupError('resourceCode 格式不正确。')
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({ appKey, appSecret, resourceCode })
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({ appKey, appSecret, resourceCode }) {
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
- `REALSEE_DEFAULT_RESOURCE_CODE=${quoteDotenvValue(resourceCode)}`,
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
- export async function writeLocalConfig(
148
- config,
149
- {
150
- root = process.cwd(),
151
- platform = process.platform,
152
- env = process.env,
153
- runCommand = run,
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
- const targetPath = path.join(path.resolve(root), '.env.development')
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
- `.env.development.${process.pid}.${randomUUID()}.tmp`,
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(renderLocalEnv(config), 'utf8')
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
+ }