@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
|
@@ -152,9 +152,22 @@ export type PanoTagContentDto =
|
|
|
152
152
|
readonly data: { readonly modelUrl: string };
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
+
export type PanoTagPointStyleDto = {
|
|
156
|
+
readonly url?: string;
|
|
157
|
+
readonly width?: number;
|
|
158
|
+
readonly scale?: number;
|
|
159
|
+
readonly enabled?: boolean;
|
|
160
|
+
readonly ratio?: number;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export type PanoTagStyleDto = {
|
|
164
|
+
readonly point: PanoTagPointStyleDto;
|
|
165
|
+
};
|
|
166
|
+
|
|
155
167
|
export type PanoTagDto = {
|
|
156
168
|
readonly id?: string | number;
|
|
157
169
|
readonly enabled?: boolean;
|
|
170
|
+
readonly style?: PanoTagStyleDto;
|
|
158
171
|
} & PanoTagAnchorDto &
|
|
159
172
|
PanoTagContentDto;
|
|
160
173
|
|
|
@@ -164,7 +177,7 @@ export interface PanoTagCapabilityDto {
|
|
|
164
177
|
}
|
|
165
178
|
```
|
|
166
179
|
|
|
167
|
-
The starter intentionally omits `globalConfig` and `contentTypeConfig`: the installed SDK config allows callbacks, DOM values, and Three objects that are not a safe BFF contract. Add an explicit JSON-safe whitelist DTO only when the product needs a specific config field. Canonical 3.81.2 content types not shown above (`Marketing`, `MediaPlane`, `MediaModel`, `Custom`, and `Unknown`) must be rejected until a product-specific serializable schema, parser arm, and converter arm are supplied. Never accept arbitrary `Custom`/`Unknown` objects by default.
|
|
180
|
+
The starter intentionally limits `style` to the JSON-safe `point` artwork fields used by ordinary tag icons. Do not pass the complete SDK `TagStyle` through the BFF: add another explicit DTO/parser/converter whitelist only when the product needs `boxOrpolygon`, `mask`, animation, or another style field. The starter also omits `globalConfig` and `contentTypeConfig`: the installed SDK config allows callbacks, DOM values, and Three objects that are not a safe BFF contract. Add an explicit JSON-safe whitelist DTO only when the product needs a specific config field. Canonical 3.81.2 content types not shown above (`Marketing`, `MediaPlane`, `MediaModel`, `Custom`, and `Unknown`) must be rejected until a product-specific serializable schema, parser arm, and converter arm are supplied. Never accept arbitrary `Custom`/`Unknown` objects by default.
|
|
168
181
|
|
|
169
182
|
### Runtime parser
|
|
170
183
|
|
|
@@ -240,6 +253,57 @@ const parseMediaItem = (value: unknown, path: string): MediaItemDto => {
|
|
|
240
253
|
}
|
|
241
254
|
};
|
|
242
255
|
|
|
256
|
+
const optionalPositiveFinite = (
|
|
257
|
+
record: Record<string, unknown>,
|
|
258
|
+
key: string,
|
|
259
|
+
path: string,
|
|
260
|
+
) => {
|
|
261
|
+
const value = record[key];
|
|
262
|
+
if (value === undefined) return undefined;
|
|
263
|
+
const parsed = requireFinite(value, `${path}.${key}`);
|
|
264
|
+
if (parsed <= 0) {
|
|
265
|
+
throw new RealseeOpenApiError(`${path}.${key} 必须大于 0。`);
|
|
266
|
+
}
|
|
267
|
+
return parsed;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const optionalBoolean = (
|
|
271
|
+
record: Record<string, unknown>,
|
|
272
|
+
key: string,
|
|
273
|
+
path: string,
|
|
274
|
+
) => {
|
|
275
|
+
const value = record[key];
|
|
276
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
277
|
+
throw new RealseeOpenApiError(`${path}.${key} 必须是布尔值。`);
|
|
278
|
+
}
|
|
279
|
+
return value;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const parseStyle = (
|
|
283
|
+
record: Record<string, unknown>,
|
|
284
|
+
path: string,
|
|
285
|
+
): PanoTagStyleDto | undefined => {
|
|
286
|
+
if (record.style === undefined) return undefined;
|
|
287
|
+
const style = requireRecord(record.style, `${path}.style`);
|
|
288
|
+
if (style.point === undefined) return undefined;
|
|
289
|
+
const pointPath = `${path}.style.point`;
|
|
290
|
+
const point = requireRecord(style.point, pointPath);
|
|
291
|
+
const url = optionalString(point, "url", pointPath);
|
|
292
|
+
const width = optionalPositiveFinite(point, "width", pointPath);
|
|
293
|
+
const scale = optionalPositiveFinite(point, "scale", pointPath);
|
|
294
|
+
const enabled = optionalBoolean(point, "enabled", pointPath);
|
|
295
|
+
const ratio = optionalPositiveFinite(point, "ratio", pointPath);
|
|
296
|
+
return {
|
|
297
|
+
point: {
|
|
298
|
+
...(url ? { url } : {}),
|
|
299
|
+
...(width !== undefined ? { width } : {}),
|
|
300
|
+
...(scale !== undefined ? { scale } : {}),
|
|
301
|
+
...(enabled !== undefined ? { enabled } : {}),
|
|
302
|
+
...(ratio !== undefined ? { ratio } : {}),
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
};
|
|
306
|
+
|
|
243
307
|
const parseAnchor = (
|
|
244
308
|
record: Record<string, unknown>,
|
|
245
309
|
path: string,
|
|
@@ -403,20 +467,22 @@ const parsePanoTag = (value: unknown, index: number): PanoTagDto => {
|
|
|
403
467
|
if (record.enabled !== undefined && typeof record.enabled !== "boolean") {
|
|
404
468
|
throw new RealseeOpenApiError(`${path}.enabled 必须是布尔值。`);
|
|
405
469
|
}
|
|
470
|
+
const style = parseStyle(record, path);
|
|
406
471
|
return {
|
|
407
472
|
...(id !== undefined ? { id } : {}),
|
|
408
473
|
...(record.enabled !== undefined ? { enabled: record.enabled } : {}),
|
|
474
|
+
...(style ? { style } : {}),
|
|
409
475
|
...parseAnchor(record, path),
|
|
410
476
|
...parseContent(record, path),
|
|
411
477
|
};
|
|
412
478
|
};
|
|
413
479
|
```
|
|
414
480
|
|
|
415
|
-
The parser above implements every discriminator in the starter DTO and rejects all others rather than passing them through. Return `{ version?, tagList: data.tagList.map(parsePanoTag) }` only after the complete array parses. Validate optional `version` as a non-empty string when present.
|
|
481
|
+
The parser above implements every discriminator in the starter DTO and rejects all others rather than passing them through. It preserves only `style.point.url`, `width`, `scale`, `enabled`, and `ratio`; all other SDK style fields require their own explicit DTO, parser, and converter support. Return `{ version?, tagList: data.tagList.map(parsePanoTag) }` only after the complete array parses. Validate optional `version` as a non-empty string when present.
|
|
416
482
|
|
|
417
483
|
### Exhaustive browser conversion and feature ownership
|
|
418
484
|
|
|
419
|
-
Convert readonly DTO arrays into mutable SDK arrays and declare the converter's return type as `Tags`; do not cast at the final boundary:
|
|
485
|
+
Convert readonly DTO arrays into mutable SDK arrays and declare the converter's return type as `Tags`; do not cast at the final boundary. The snippet assumes `featureThumbnailURL` is imported from the application-owned image adapter in the next recipe so every plugin image, including `style.point.url`, is validated and bounded at this boundary:
|
|
420
486
|
|
|
421
487
|
```ts
|
|
422
488
|
import {
|
|
@@ -496,13 +562,15 @@ const toSdkMedia = (media: MediaItemDto) =>
|
|
|
496
562
|
media.type === "Image"
|
|
497
563
|
? {
|
|
498
564
|
type: "Image" as const,
|
|
499
|
-
url: media.url,
|
|
500
|
-
|
|
565
|
+
url: featureThumbnailURL(media.url, 1024),
|
|
566
|
+
thumbnail: featureThumbnailURL(media.thumbnail ?? media.url, 256),
|
|
501
567
|
}
|
|
502
568
|
: {
|
|
503
569
|
type: "Video" as const,
|
|
504
570
|
url: media.url,
|
|
505
|
-
...(media.videoCoverUrl
|
|
571
|
+
...(media.videoCoverUrl
|
|
572
|
+
? { videoCoverUrl: featureThumbnailURL(media.videoCoverUrl, 512) }
|
|
573
|
+
: {}),
|
|
506
574
|
};
|
|
507
575
|
|
|
508
576
|
const toSdkContent = (tag: PanoTagContentDto) => {
|
|
@@ -535,9 +603,24 @@ const toSdkContent = (tag: PanoTagContentDto) => {
|
|
|
535
603
|
}
|
|
536
604
|
};
|
|
537
605
|
|
|
606
|
+
const toSdkStyle = (style: PanoTagStyleDto) => ({
|
|
607
|
+
point: {
|
|
608
|
+
...(style.point.url
|
|
609
|
+
? { url: featureThumbnailURL(style.point.url, 256) }
|
|
610
|
+
: {}),
|
|
611
|
+
...(style.point.width !== undefined ? { width: style.point.width } : {}),
|
|
612
|
+
...(style.point.scale !== undefined ? { scale: style.point.scale } : {}),
|
|
613
|
+
...(style.point.enabled !== undefined
|
|
614
|
+
? { enabled: style.point.enabled }
|
|
615
|
+
: {}),
|
|
616
|
+
...(style.point.ratio !== undefined ? { ratio: style.point.ratio } : {}),
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
|
|
538
620
|
const toSdkTag = (tag: PanoTagDto): Tags["tagList"][number] => ({
|
|
539
621
|
...(tag.id !== undefined ? { id: tag.id } : {}),
|
|
540
622
|
...(tag.enabled !== undefined ? { enabled: tag.enabled } : {}),
|
|
623
|
+
...(tag.style ? { style: toSdkStyle(tag.style) } : {}),
|
|
541
624
|
...toSdkAnchor(tag),
|
|
542
625
|
contentType: tag.contentType,
|
|
543
626
|
data: toSdkContent(tag),
|
|
@@ -594,26 +677,7 @@ export const featureThumbnailURL = (source: string, size: 256 | 512 | 1024) =>
|
|
|
594
677
|
});
|
|
595
678
|
```
|
|
596
679
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
```ts
|
|
600
|
-
const toSdkMedia = (media: MediaItemDto) =>
|
|
601
|
-
media.type === "Image"
|
|
602
|
-
? {
|
|
603
|
-
type: "Image" as const,
|
|
604
|
-
// Popover artwork does not need the original.
|
|
605
|
-
url: featureThumbnailURL(media.url, 1024),
|
|
606
|
-
// A list thumbnail needs far less; fall back to the same source when absent.
|
|
607
|
-
thumbnail: featureThumbnailURL(media.thumbnail ?? media.url, 256),
|
|
608
|
-
}
|
|
609
|
-
: {
|
|
610
|
-
type: "Video" as const,
|
|
611
|
-
url: media.url, // video streams are not image-transform targets
|
|
612
|
-
...(media.videoCoverUrl
|
|
613
|
-
? { videoCoverUrl: featureThumbnailURL(media.videoCoverUrl, 512) }
|
|
614
|
-
: {}),
|
|
615
|
-
};
|
|
616
|
-
```
|
|
680
|
+
The PanoTag converter above applies this adapter at the DTO-to-SDK boundary: content artwork uses 256–1024 px variants, video cover artwork uses 512 px, and `style.point.url` uses a 256 px variant. Keep video stream URLs unchanged.
|
|
617
681
|
|
|
618
682
|
Per-capability targets:
|
|
619
683
|
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
- Accept Work through a `WorkSource` boundary, not from a hardcoded business endpoint or local `work.json`.
|
|
6
6
|
- The browser route is `/{resourceCode}` and the Viewer calls only the same-origin `/api/work/{resourceCode}` contract. Do not add `VITE_WORK_URL` or embed a business endpoint.
|
|
7
7
|
- The generated Fastify BFF is a replaceable TypeScript example. Keep the shared contracts in `server/types.ts`, Realsee token exchange/cache and upstream calls in `server/realsee-open-api.ts`, the framework-neutral browser envelope in `server/work-service.ts`, and HTTP adaptation in `server/work-route.ts`. Node runs these files directly through type stripping, so `server/` must stay erasable-only and keep explicit `.ts` import extensions.
|
|
8
|
-
- A Go or other backend may replace `server/`, but must preserve `GET /api/work/{resourceCode}` and return `{ work, workCode, baseURL?, capabilities?: { modelFloorplan } }` without exposing credentials or tokens.
|
|
9
|
-
- Use the single `.env.example` as the server/deployment schema. Only the BFF reads `REALSEE_GATEWAY_ENDPOINT`, `REALSEE_GATEWAY_APP_KEY` and `
|
|
8
|
+
- A Go or other backend may replace `server/`, but must preserve `GET /api/work/{resourceCode}` and return `{ work, workCode, baseURL?, capabilities?: { modelFloorplan } }` without exposing credentials or tokens. `GET /api/work` is only an optional `REALSEE_DEFAULT_RESOURCE_CODE` fallback; without one it returns a controlled 4xx before any upstream request.
|
|
9
|
+
- Use the single `.env.example` as the server/deployment schema. `npm run setup` always writes AK/SK and writes `REALSEE_DEFAULT_RESOURCE_CODE` only when the paired Portal approval (or the compatible v1 payload) supplies an optional `resourceCode`; its default path is Portal pairing (`--no-wait`/`--complete` split flow for agents, `--paste` as the hidden-input fallback), and `REALSEE_GATEWAY_APP_KEY`/`REALSEE_GATEWAY_APP_SECRET` environment variables take precedence and skip pairing. Success and failure envelopes use `ok` (`{"ok":true,...}` on stdout, `{"ok":false,"error":{...}}` on stderr) — judge them with `ok===true`, never a `code` field — and only a masked AK may appear in output. Only the BFF reads `REALSEE_GATEWAY_ENDPOINT`, `REALSEE_GATEWAY_APP_KEY`, `REALSEE_GATEWAY_APP_SECRET` and optional `REALSEE_DEFAULT_RESOURCE_CODE`. Browser code reads none of them. The default `deploy.json.requiredEnv` contains only AK/SK.
|
|
10
10
|
- Keep the real `REALSEE_GATEWAY_APP_SECRET`, access token and OpenAPI authorization logic in the BFF environment or secret manager. Never expose them through `VITE_*`, Vite `define/envPrefix`, client source, query strings, browser request headers, logs or returned Work envelopes.
|
|
11
11
|
- Carry `workCode`, optional `baseURL`, raw Work and optional initial State together. Preset feature input must be explicit; only `presetData.panoTagList` or a validated named feature-data contract may feed a plugin.
|
|
12
12
|
- Never infer PanoTag from generic `tags`, `tagList` or `tag_list` fields. `/open/v3/hot/list` is authoritative only after its envelope and typed tag records are validated by a named BFF adapter.
|
|
@@ -39,6 +39,13 @@ const expectedDev = {
|
|
|
39
39
|
'typescript-eslint': '8.68.0',
|
|
40
40
|
vite: '5.4.14',
|
|
41
41
|
}
|
|
42
|
+
const releasePackageFormat = 'node-vite-bff-v1'
|
|
43
|
+
const releaseZipPackage = 'adm-zip'
|
|
44
|
+
const releaseZipVersion = '0.6.0'
|
|
45
|
+
const releaseRequiredEnvKeys = [
|
|
46
|
+
'REALSEE_GATEWAY_APP_KEY',
|
|
47
|
+
'REALSEE_GATEWAY_APP_SECRET',
|
|
48
|
+
]
|
|
42
49
|
const requiredEnvKeys = [
|
|
43
50
|
'APP_ID',
|
|
44
51
|
'REALSEE_GATEWAY_ENDPOINT',
|
|
@@ -46,6 +53,8 @@ const requiredEnvKeys = [
|
|
|
46
53
|
'REALSEE_GATEWAY_APP_SECRET',
|
|
47
54
|
'REALSEE_SOURCE',
|
|
48
55
|
]
|
|
56
|
+
const optionalEnvExampleMarker =
|
|
57
|
+
'# REALSEE_DEFAULT_RESOURCE_CODE=example-resource-code'
|
|
49
58
|
const credentialEnvKeys = [
|
|
50
59
|
'REALSEE_GATEWAY_APP_KEY',
|
|
51
60
|
'REALSEE_GATEWAY_APP_SECRET',
|
|
@@ -54,6 +63,7 @@ const secretEnvKeys = ['REALSEE_GATEWAY_APP_SECRET']
|
|
|
54
63
|
const requiredFiles = [
|
|
55
64
|
'package.json',
|
|
56
65
|
'package-lock.json',
|
|
66
|
+
'deploy.json',
|
|
57
67
|
'README.md',
|
|
58
68
|
'index.html',
|
|
59
69
|
'tsconfig.json',
|
|
@@ -67,6 +77,12 @@ const requiredFiles = [
|
|
|
67
77
|
'.vr-cli.json',
|
|
68
78
|
'eslint.config.mjs',
|
|
69
79
|
'prettier.config.mjs',
|
|
80
|
+
'scripts/package-vibe-release.mjs',
|
|
81
|
+
'scripts/setup-config.mjs',
|
|
82
|
+
'scripts/setup-config.test.mjs',
|
|
83
|
+
'scripts/setup.mjs',
|
|
84
|
+
'scripts/pairing.mjs',
|
|
85
|
+
'scripts/pairing.test.mjs',
|
|
70
86
|
'server/types.ts',
|
|
71
87
|
'server/config.ts',
|
|
72
88
|
'server/check-config.ts',
|
|
@@ -361,9 +377,11 @@ function versionAtLeast(actual, required) {
|
|
|
361
377
|
if (actualVersion.prerelease === requiredVersion.prerelease) return true
|
|
362
378
|
if (actualVersion.prerelease === null) return true
|
|
363
379
|
if (requiredVersion.prerelease === null) return false
|
|
364
|
-
return
|
|
365
|
-
|
|
366
|
-
|
|
380
|
+
return (
|
|
381
|
+
actualVersion.prerelease.localeCompare(requiredVersion.prerelease, 'en', {
|
|
382
|
+
numeric: true,
|
|
383
|
+
}) >= 0
|
|
384
|
+
)
|
|
367
385
|
}
|
|
368
386
|
|
|
369
387
|
async function currentNpmVersion() {
|
|
@@ -435,6 +453,11 @@ try {
|
|
|
435
453
|
failures.push(`engines.node must equal ${nodeEngine}`)
|
|
436
454
|
if (pkg.engines?.npm !== npmEngine)
|
|
437
455
|
failures.push(`engines.npm must equal ${npmEngine}`)
|
|
456
|
+
if (pkg.devDependencies?.[releaseZipPackage] !== releaseZipVersion) {
|
|
457
|
+
failures.push(
|
|
458
|
+
`${releaseZipPackage} must stay locked at ${releaseZipVersion} for release packaging`,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
438
461
|
for (const [declared, baseline] of [
|
|
439
462
|
[pkg.dependencies, expected],
|
|
440
463
|
[pkg.devDependencies, expectedDev],
|
|
@@ -477,6 +500,17 @@ try {
|
|
|
477
500
|
}
|
|
478
501
|
for (const [name, markers] of [
|
|
479
502
|
['dev', ['check:env', 'concurrently']],
|
|
503
|
+
['setup', ['scripts/setup.mjs']],
|
|
504
|
+
['setup:complete', ['scripts/setup.mjs', '--complete']],
|
|
505
|
+
[
|
|
506
|
+
'test:setup',
|
|
507
|
+
[
|
|
508
|
+
'--test',
|
|
509
|
+
'scripts/setup-config.test.mjs',
|
|
510
|
+
'scripts/project-identity.test.mjs',
|
|
511
|
+
'scripts/pairing.test.mjs',
|
|
512
|
+
],
|
|
513
|
+
],
|
|
480
514
|
['dev:bff', ['--env-file-if-exists=.env.development', 'server/index.ts']],
|
|
481
515
|
['dev:web', ['vite']],
|
|
482
516
|
[
|
|
@@ -493,6 +527,7 @@ try {
|
|
|
493
527
|
['check:bff', ['typecheck:bff']],
|
|
494
528
|
['deps:relock', ['npm install --package-lock-only']],
|
|
495
529
|
['deps:install', ['npm ci']],
|
|
530
|
+
['package:vibe-release', ['node scripts/package-vibe-release.mjs']],
|
|
496
531
|
[
|
|
497
532
|
'start',
|
|
498
533
|
[
|
|
@@ -526,13 +561,17 @@ try {
|
|
|
526
561
|
'package-lock.json root name/version must match package.json',
|
|
527
562
|
)
|
|
528
563
|
}
|
|
564
|
+
if (
|
|
565
|
+
lockRoot.devDependencies?.[releaseZipPackage] !== releaseZipVersion ||
|
|
566
|
+
lockfile.packages?.[`node_modules/${releaseZipPackage}`]?.version !==
|
|
567
|
+
releaseZipVersion
|
|
568
|
+
) {
|
|
569
|
+
failures.push(
|
|
570
|
+
`package-lock.json must declare and resolve ${releaseZipPackage}@${releaseZipVersion}`,
|
|
571
|
+
)
|
|
572
|
+
}
|
|
529
573
|
for (const [lockDeclared, declared, baseline, label] of [
|
|
530
|
-
[
|
|
531
|
-
lockRoot.dependencies,
|
|
532
|
-
pkg.dependencies,
|
|
533
|
-
expected,
|
|
534
|
-
'dependencies',
|
|
535
|
-
],
|
|
574
|
+
[lockRoot.dependencies, pkg.dependencies, expected, 'dependencies'],
|
|
536
575
|
[
|
|
537
576
|
lockRoot.devDependencies,
|
|
538
577
|
pkg.devDependencies,
|
|
@@ -582,6 +621,123 @@ try {
|
|
|
582
621
|
failures.push(`package metadata: ${String(error)}`)
|
|
583
622
|
}
|
|
584
623
|
|
|
624
|
+
if ((await exists('deploy.json')) && (await exists('package.json'))) {
|
|
625
|
+
try {
|
|
626
|
+
const manifest = await readFile(path.join(root, 'deploy.json'), 'utf8').then(
|
|
627
|
+
JSON.parse,
|
|
628
|
+
)
|
|
629
|
+
const isRecord = (value) =>
|
|
630
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
631
|
+
const manifestErrors = []
|
|
632
|
+
let requiredEnvError = null
|
|
633
|
+
let healthError = null
|
|
634
|
+
|
|
635
|
+
if (!isRecord(manifest)) {
|
|
636
|
+
manifestErrors.push('must be a JSON object')
|
|
637
|
+
} else {
|
|
638
|
+
if (manifest.packageFormat !== releasePackageFormat) {
|
|
639
|
+
manifestErrors.push(
|
|
640
|
+
`packageFormat must equal ${releasePackageFormat}`,
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
const allowedFields = new Set([
|
|
644
|
+
'packageFormat',
|
|
645
|
+
'name',
|
|
646
|
+
'requiredEnv',
|
|
647
|
+
'resources',
|
|
648
|
+
'domain',
|
|
649
|
+
'healthCheck',
|
|
650
|
+
])
|
|
651
|
+
const unsupportedField = Object.keys(manifest).find(
|
|
652
|
+
(field) => !allowedFields.has(field),
|
|
653
|
+
)
|
|
654
|
+
if (unsupportedField) {
|
|
655
|
+
manifestErrors.push(`unsupported field ${unsupportedField}`)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (
|
|
659
|
+
!Array.isArray(manifest.requiredEnv) ||
|
|
660
|
+
manifest.requiredEnv.length > 20
|
|
661
|
+
) {
|
|
662
|
+
requiredEnvError = 'must be an array with no more than 20 entries'
|
|
663
|
+
} else {
|
|
664
|
+
const names = new Set()
|
|
665
|
+
for (const raw of manifest.requiredEnv) {
|
|
666
|
+
const name = String(raw ?? '').trim()
|
|
667
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
668
|
+
requiredEnvError = `contains invalid name ${name || '(empty)'}`
|
|
669
|
+
break
|
|
670
|
+
}
|
|
671
|
+
if (names.has(name)) {
|
|
672
|
+
requiredEnvError = `contains duplicate name ${name}`
|
|
673
|
+
break
|
|
674
|
+
}
|
|
675
|
+
names.add(name)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const healthCheck = manifest.healthCheck
|
|
680
|
+
if (healthCheck !== undefined) {
|
|
681
|
+
if (!isRecord(healthCheck)) {
|
|
682
|
+
healthError = 'must be an object'
|
|
683
|
+
} else {
|
|
684
|
+
const allowedHealthFields = new Set([
|
|
685
|
+
'enabled',
|
|
686
|
+
'path',
|
|
687
|
+
'timeoutMs',
|
|
688
|
+
'retries',
|
|
689
|
+
])
|
|
690
|
+
const unsupportedHealthField = Object.keys(healthCheck).find(
|
|
691
|
+
(field) => !allowedHealthFields.has(field),
|
|
692
|
+
)
|
|
693
|
+
if (unsupportedHealthField) {
|
|
694
|
+
healthError = `contains unsupported field ${unsupportedHealthField}`
|
|
695
|
+
} else if (
|
|
696
|
+
healthCheck.enabled !== undefined &&
|
|
697
|
+
healthCheck.enabled !== true
|
|
698
|
+
) {
|
|
699
|
+
healthError = 'enabled must be true when present'
|
|
700
|
+
} else if (
|
|
701
|
+
healthCheck.path !== undefined &&
|
|
702
|
+
healthCheck.path !== '/api/health'
|
|
703
|
+
) {
|
|
704
|
+
healthError = 'path must be /api/health when present'
|
|
705
|
+
} else if (healthCheck.timeoutMs !== undefined) {
|
|
706
|
+
const timeoutMs = Number(healthCheck.timeoutMs)
|
|
707
|
+
if (
|
|
708
|
+
!Number.isFinite(timeoutMs) ||
|
|
709
|
+
timeoutMs < 500 ||
|
|
710
|
+
timeoutMs > 30_000
|
|
711
|
+
) {
|
|
712
|
+
healthError = 'timeoutMs must be between 500 and 30000'
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (!healthError && healthCheck.retries !== undefined) {
|
|
716
|
+
const retries = Number(healthCheck.retries)
|
|
717
|
+
if (!Number.isInteger(retries) || retries < 1 || retries > 60) {
|
|
718
|
+
healthError = 'retries must be an integer between 1 and 60'
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (manifestErrors.length > 0) {
|
|
726
|
+
failures.push(`deploy.json manifest ${manifestErrors.join('; ')}`)
|
|
727
|
+
}
|
|
728
|
+
if (requiredEnvError) {
|
|
729
|
+
failures.push(
|
|
730
|
+
`deploy.json.requiredEnv ${requiredEnvError}; generated default is ${releaseRequiredEnvKeys.join(', ')}`,
|
|
731
|
+
)
|
|
732
|
+
}
|
|
733
|
+
if (healthError) {
|
|
734
|
+
failures.push(`deploy.json.healthCheck ${healthError}`)
|
|
735
|
+
}
|
|
736
|
+
} catch (error) {
|
|
737
|
+
failures.push(`deploy.json: ${String(error)}`)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
585
741
|
for (const file of requiredFiles) {
|
|
586
742
|
if (!(await exists(file))) failures.push(`missing ${file}`)
|
|
587
743
|
}
|
|
@@ -727,7 +883,9 @@ if (await exists('src/vr/work/WorkSource.ts')) {
|
|
|
727
883
|
for (const marker of [
|
|
728
884
|
'resourceCodeFromPathname',
|
|
729
885
|
'window.location.pathname',
|
|
886
|
+
'segments.length === 0',
|
|
730
887
|
'segments.length !== 1',
|
|
888
|
+
": '/api/work'",
|
|
731
889
|
'`/api/work/${encodeURIComponent(resourceCode)}`',
|
|
732
890
|
'asExplicitPanoTagList',
|
|
733
891
|
'hasCanonicalPanoTagData(value.contentType, value.data)',
|
|
@@ -1252,12 +1410,21 @@ if (await exists('src/styles.css')) {
|
|
|
1252
1410
|
}
|
|
1253
1411
|
|
|
1254
1412
|
if (await exists('.env.example')) {
|
|
1255
|
-
const
|
|
1256
|
-
|
|
1257
|
-
)
|
|
1413
|
+
const exampleSource = await readFile(path.join(root, '.env.example'), 'utf8')
|
|
1414
|
+
const values = parseEnv(exampleSource)
|
|
1258
1415
|
for (const key of requiredEnvKeys) {
|
|
1259
1416
|
if (!values.has(key)) failures.push(`.env.example missing ${key}`)
|
|
1260
1417
|
}
|
|
1418
|
+
if (!exampleSource.includes(optionalEnvExampleMarker)) {
|
|
1419
|
+
failures.push(
|
|
1420
|
+
`.env.example missing optional resource fallback example: ${optionalEnvExampleMarker}`,
|
|
1421
|
+
)
|
|
1422
|
+
}
|
|
1423
|
+
if (values.has('REALSEE_DEFAULT_RESOURCE_CODE')) {
|
|
1424
|
+
failures.push(
|
|
1425
|
+
'.env.example must keep REALSEE_DEFAULT_RESOURCE_CODE commented as optional',
|
|
1426
|
+
)
|
|
1427
|
+
}
|
|
1261
1428
|
for (const [key, value] of values) {
|
|
1262
1429
|
if (key.startsWith('VITE_WORK_'))
|
|
1263
1430
|
failures.push(
|
|
@@ -1304,7 +1471,13 @@ if (await exists('.gitignore')) {
|
|
|
1304
1471
|
for (const [relativePath, markers] of [
|
|
1305
1472
|
[
|
|
1306
1473
|
'server/types.ts',
|
|
1307
|
-
[
|
|
1474
|
+
[
|
|
1475
|
+
'ServerConfig',
|
|
1476
|
+
'WorkEnvelope',
|
|
1477
|
+
'WorkDetailClient',
|
|
1478
|
+
'WorkService',
|
|
1479
|
+
'defaultResourceCode: string | undefined',
|
|
1480
|
+
],
|
|
1308
1481
|
],
|
|
1309
1482
|
[
|
|
1310
1483
|
'server/config.ts',
|
|
@@ -1312,6 +1485,10 @@ for (const [relativePath, markers] of [
|
|
|
1312
1485
|
'loadServerConfig',
|
|
1313
1486
|
'REALSEE_GATEWAY_APP_KEY',
|
|
1314
1487
|
'REALSEE_GATEWAY_APP_SECRET',
|
|
1488
|
+
'REALSEE_DEFAULT_RESOURCE_CODE',
|
|
1489
|
+
'optionalResourceCode',
|
|
1490
|
+
'if (!nonEmptyString(value)) return undefined',
|
|
1491
|
+
'^[A-Za-z0-9_-]{1,128}$',
|
|
1315
1492
|
"gatewayEndpoint.protocol !== 'https:'",
|
|
1316
1493
|
'): ServerConfig {',
|
|
1317
1494
|
],
|
|
@@ -1346,7 +1523,10 @@ for (const [relativePath, markers] of [
|
|
|
1346
1523
|
[
|
|
1347
1524
|
'server/work-route.ts',
|
|
1348
1525
|
[
|
|
1526
|
+
"'/api/work'",
|
|
1349
1527
|
"'/api/work/:resourceCode'",
|
|
1528
|
+
'defaultResourceCode: string | undefined',
|
|
1529
|
+
"error: 'RESOURCE_CODE_REQUIRED'",
|
|
1350
1530
|
'getWorkByResourceCode(resourceCode, {',
|
|
1351
1531
|
'signal: requestAbort.signal',
|
|
1352
1532
|
'WORK_UPSTREAM_FAILED',
|
|
@@ -1357,6 +1537,7 @@ for (const [relativePath, markers] of [
|
|
|
1357
1537
|
[
|
|
1358
1538
|
'Fastify({ logger })',
|
|
1359
1539
|
"'/api/health'",
|
|
1540
|
+
'defaultResourceCode: string | undefined',
|
|
1360
1541
|
'fastifyStatic',
|
|
1361
1542
|
'serveFrontend',
|
|
1362
1543
|
"reply.sendFile('index.html')",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
# Fastify BFF server-only
|
|
2
|
-
# The browser calls only same-origin /api/work
|
|
1
|
+
# Fastify BFF server-only schema. Run npm run setup to create .env.development safely.
|
|
2
|
+
# The browser calls only same-origin /api/work; never rename these values to VITE_*.
|
|
3
3
|
# 标识当前的应用 ID(可选部署元数据)
|
|
4
4
|
APP_ID=next-vr-develpment
|
|
5
5
|
# Realsee 开放平台网关(中文版;海外版为 https://app-gateway.realsee.jp)
|
|
@@ -8,5 +8,7 @@ REALSEE_GATEWAY_ENDPOINT=https://app-gateway.realsee.cn
|
|
|
8
8
|
REALSEE_GATEWAY_APP_KEY=*
|
|
9
9
|
# Realsee 的网关 app secret
|
|
10
10
|
REALSEE_GATEWAY_APP_SECRET=*
|
|
11
|
+
# 可选的根路由 fallback VR ID(resourceCode);未配置时请使用 /{resourceCode}
|
|
12
|
+
# REALSEE_DEFAULT_RESOURCE_CODE=example-resource-code
|
|
11
13
|
# Realsee 的企业账号 ID(可选部署元数据)
|
|
12
14
|
REALSEE_SOURCE=REALSEE_TECH
|
package/templates/base/README.md
CHANGED
|
@@ -11,16 +11,30 @@ Use Node.js `22.23.2` LTS from `.nvmrc` and `.node-version`: it is the current s
|
|
|
11
11
|
## Start
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
cp .env.example .env.development
|
|
15
|
-
# Fill REALSEE_GATEWAY_APP_KEY and REALSEE_GATEWAY_APP_SECRET in .env.development.
|
|
16
14
|
npm ci
|
|
15
|
+
npm run setup
|
|
17
16
|
npm run dev
|
|
18
|
-
# Open http://127.0.0.1:8087/
|
|
17
|
+
# Open http://127.0.0.1:8087/{resourceCode}.
|
|
19
18
|
```
|
|
20
19
|
|
|
20
|
+
`npm run setup` pairs this machine with the Realsee China Portal: it prints a verification link and an `XXXX-XXXX` short code, the user confirms the request in the browser, and the command polls until the server reports a terminal state before writing the gitignored server-only `.env.development`. Agents and harnesses that only see a command's final output must use the split flow instead:
|
|
21
|
+
|
|
22
|
+
Each local project receives a stable, random `project_instance_id` on its first pairing attempt. It is stored in the gitignored `.realsee/project.json` and is not a credential. The Portal shows the project name, the first eight characters of this ID, and the requesting machine in the existing `client_name` field so that same-name projects can be distinguished. Do not copy `.realsee/` into source control or a release package.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm run setup -- --no-wait --json # prints {ok:true,data:{status:"pending",...}} and exits 2
|
|
26
|
+
npm run setup -- --complete # after the user confirms; polls, writes .env.development
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`poll_secret` is never printed: it stays in process memory and in the `0600`, gitignored `.realsee/pairing-continuation.json`. The split-flow pending result includes non-sensitive `client_name` and `replacing_config` so an AI assistant can identify the current project and warn before replacement. Success prints `{"ok":true,...}` on stdout — judge it with `ok===true`, never a `code` field — and only a masked AK ever appears in output. Failures print `{"ok":false,"error":{code,message,hint}}` on stderr with exit code `0` written, `2` pending, `3` denied (never re-sent automatically), `4` expired, `5` service unavailable, `1` otherwise.
|
|
30
|
+
|
|
31
|
+
`REALSEE_GATEWAY_APP_KEY`/`REALSEE_GATEWAY_APP_SECRET` environment variables (CI, unattended installs) take precedence over both paths and skip pairing. The fallback `npm run setup -- --paste` keeps the hidden-input path for exactly one versioned single-line payload copied from the Portal spatial app creation page: the payload always contains AK/SK and may also contain an optional selected `resourceCode` fallback; existing three-field v1 payloads remain supported. Base64URL is a cross-platform transport encoding, not encryption. Never put it in chat, command arguments, pipes, redirected input, shell history, source or logs. Setup writes the file atomically with `0600` on POSIX or a current-user ACL on Windows, and omits `REALSEE_DEFAULT_RESOURCE_CODE` when no fallback is supplied.
|
|
32
|
+
|
|
33
|
+
To change the organization, Developer App, credentials, or default VR in this project, run `npm run setup` again and confirm the new request in the Portal. Successful setup replaces only this project's `.env.development`; a denied or expired request leaves the existing file unchanged. Restart `npm run dev` afterward. Other local projects have independent configuration and project IDs.
|
|
34
|
+
|
|
21
35
|
`REALSEE_GATEWAY_ENDPOINT` must be an HTTPS URL; the BFF refuses HTTP so AK/SK cannot be sent in cleartext. `npm run dev` validates `.env.development` before starting Fastify and Vite. After changing `.env.development`, stop the existing dev command and start it again so Node reloads the environment.
|
|
22
36
|
|
|
23
|
-
Development runs the Fastify BFF on `127.0.0.1:8088` and Vite on `127.0.0.1:8087`; Vite proxies `/api` to Fastify
|
|
37
|
+
Development runs the Fastify BFF on `127.0.0.1:8088` and Vite on `127.0.0.1:8087`; Vite proxies `/api` to Fastify. The canonical Work route is `GET /api/work/{resourceCode}`, reached from `/{resourceCode}`. The root `/` route calls `GET /api/work`: it uses `REALSEE_DEFAULT_RESOURCE_CODE` only when that optional fallback is configured, otherwise it returns the controlled `RESOURCE_CODE_REQUIRED` 400 response without calling upstream. Port 8087 is non-privileged; do not change it to privileged port 517.
|
|
24
38
|
|
|
25
39
|
The BFF posts `REALSEE_GATEWAY_APP_KEY` and `REALSEE_GATEWAY_APP_SECRET` as form fields to `/auth/access_token`, caches the 5-minute token until shortly before `expire_at`, retries once after an upstream 401, then calls `GET /open/v3/vr/info?resource_code={resourceCode}` with the raw token in the `Authorization` header. It returns only the browser-safe `{ work, workCode, baseURL? }` envelope. Generic `tags` are not inferred as PanoTag data. The default gateway is `https://app-gateway.realsee.cn`; overseas deployments use `https://app-gateway.realsee.jp`. See the official [Realsee OpenAPI reference](https://open-platform.realsee.com/developer/openapi/?id=1001).
|
|
26
40
|
|
|
@@ -31,9 +45,21 @@ npm run build
|
|
|
31
45
|
npm start
|
|
32
46
|
```
|
|
33
47
|
|
|
48
|
+
## Package for Vibe Release
|
|
49
|
+
|
|
50
|
+
Create the source ZIP accepted by Vibe Release with the project-local command:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm run package:vibe-release
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The command runs a clean `npm ci` and the canonical production build, then writes `release/<package-name>-node-vite-bff-v1.zip`. The ZIP contains source files at its root and intentionally excludes local `node_modules`, `dist`, release output, `.env*`, credentials, logs, and editor/Agent state. Vibe Release rebuilds `dist` in Linux.
|
|
57
|
+
|
|
58
|
+
`deploy.json` declares required runtime variable names only. The generated default requires exactly `REALSEE_GATEWAY_APP_KEY` and `REALSEE_GATEWAY_APP_SECRET`; `REALSEE_DEFAULT_RESOURCE_CODE` is an optional root-route fallback and belongs in `requiredEnv` only when an application deliberately makes it mandatory. Configure declared values in Vibe Release settings; never write AK/SK or resource values into the ZIP.
|
|
59
|
+
|
|
34
60
|
## Replace the example BFF
|
|
35
61
|
|
|
36
|
-
The browser
|
|
62
|
+
The browser always supports the explicit `GET /api/work/{resourceCode}` contract; `GET /api/work` is only the optional fallback form. Fastify is an example implementation, not an application architecture requirement:
|
|
37
63
|
|
|
38
64
|
- `server/types.ts` declares the server-side contracts (`ServerConfig`, `WorkEnvelope`, `WorkDetailClient`, `WorkService`);
|
|
39
65
|
- `server/realsee-open-api.ts` owns Realsee token exchange, expiry caching and upstream Work calls;
|
|
@@ -46,11 +72,11 @@ The BFF is TypeScript and runs without a build step: Node executes `.ts` directl
|
|
|
46
72
|
|
|
47
73
|
A Go, Java or other backend may replace the entire `server/` directory as long as it preserves the same-origin endpoint and response envelope. Keep AK/SK and access tokens server-side; never put them in `src/`, `VITE_*`, Vite `define/envPrefix`, URLs, browser headers, logs or bundles. AK/SK authorize the BFF to call the upstream API; if an internet-facing deployment also needs caller or tenant authorization, add that policy before the Work route instead of exposing AK/SK to the browser. The generated project does not include `work.json` or customer Work data.
|
|
48
74
|
|
|
49
|
-
The generated project has one `.env.example`. Only the Fastify BFF reads `REALSEE_GATEWAY_ENDPOINT`, `REALSEE_GATEWAY_APP_KEY` and `
|
|
75
|
+
The generated project has one `.env.example`. Only the Fastify BFF reads `REALSEE_GATEWAY_ENDPOINT`, `REALSEE_GATEWAY_APP_KEY`, `REALSEE_GATEWAY_APP_SECRET` and `REALSEE_DEFAULT_RESOURCE_CODE`; `APP_ID` and `REALSEE_SOURCE` remain optional deployment metadata. Real values belong in `.env.development`, the deployment environment or a secret manager.
|
|
50
76
|
|
|
51
77
|
## Update dependencies
|
|
52
78
|
|
|
53
|
-
The manifest intentionally declares
|
|
79
|
+
The manifest intentionally declares application/runtime dependencies and ordinary development dependencies as open minimum ranges (`>=baseline`), except for version-bound Three. The release-only `adm-zip` tool is an additional exact-pinned development dependency because archive bytes and APIs must not drift between clean packaging runs. The committed `package-lock.json` records the generator's reviewed initial resolution, so `npm ci` remains reproducible while an explicit application dependency update can move across major versions.
|
|
54
80
|
|
|
55
81
|
To update every unlocked dependency to the newest release allowed by its open range:
|
|
56
82
|
|
|
@@ -70,9 +96,10 @@ npm run build
|
|
|
70
96
|
|
|
71
97
|
`.npmrc` uses `save-prefix=>=`, so newly saved direct dependencies and explicit upgrades retain the open-minimum policy. After manually editing a minimum, use `npm run deps:relock`, then `npm run deps:install` to verify a clean lockfile install.
|
|
72
98
|
|
|
73
|
-
|
|
99
|
+
Two dependencies are exact-pinned for different boundaries:
|
|
74
100
|
|
|
75
101
|
- `three` must stay exactly `0.117.1`. Five 6.8.9 targets that release, and the generated code plus the bundled Three capability references assume the r117 API surface. `vr-cli doctor` and the portable verifier reject any other value, including ranges.
|
|
102
|
+
- `adm-zip` must stay exactly `0.6.0`. It is used only by `npm run package:vibe-release`; `vr-cli doctor` rejects archive-tool drift so clean release output is produced by the reviewed implementation.
|
|
76
103
|
- Every other dependency, including `@realsee/five`, `@realsee/dnalogel`, `@realsee/vapor`, Fastify, React, Vite, TypeScript and the lint stack, must use `>=x.y.z` with a minimum no lower than the generator baseline. Exact, caret, tilde and bounded ranges are rejected because they cannot move freely to the latest release.
|
|
77
104
|
- The lockfile's resolved direct version must satisfy both the declared minimum and the generator baseline. Raising a minimum requires relocking.
|
|
78
105
|
|