@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.
Files changed (31) hide show
  1. package/README.md +46 -29
  2. package/agent-assets/AGENTS.md +8 -5
  3. package/dist/cli.mjs +5 -5
  4. package/package.json +1 -1
  5. package/skills/realsee-vr-app/SKILL.md +1 -1
  6. package/skills/realsee-vr-app/references/dnalogel-openapi-recipes.md +90 -26
  7. package/skills/realsee-vr-app/references/five-runtime.md +2 -2
  8. package/skills/realsee-vr-app/scripts/verify-project.mjs +194 -13
  9. package/templates/base/.env.example +4 -2
  10. package/templates/base/.gitignore.template +2 -0
  11. package/templates/base/.prettierignore +2 -0
  12. package/templates/base/README.md +35 -8
  13. package/templates/base/deploy.json +11 -0
  14. package/templates/base/eslint.config.mjs +2 -0
  15. package/templates/base/package-lock.json +11 -0
  16. package/templates/base/package.json +6 -1
  17. package/templates/base/scripts/package-vibe-release.mjs +569 -0
  18. package/templates/base/scripts/pairing.mjs +564 -0
  19. package/templates/base/scripts/pairing.test.mjs +227 -0
  20. package/templates/base/scripts/project-identity.mjs +160 -0
  21. package/templates/base/scripts/project-identity.test.mjs +119 -0
  22. package/templates/base/scripts/setup-config.mjs +252 -0
  23. package/templates/base/scripts/setup-config.test.mjs +123 -0
  24. package/templates/base/scripts/setup.mjs +203 -0
  25. package/templates/base/server/app.ts +3 -1
  26. package/templates/base/server/config.ts +12 -1
  27. package/templates/base/server/index.ts +5 -1
  28. package/templates/base/server/types.ts +2 -1
  29. package/templates/base/server/work-route.ts +51 -32
  30. package/templates/base/server/work-service.ts +2 -2
  31. 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
- ...(media.thumbnail ? { thumbnail: media.thumbnail } : {}),
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 ? { videoCoverUrl: 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
- Apply it at the DTO-to-SDK conversion boundary, where the display context is known:
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 `REALSEE_GATEWAY_APP_SECRET`; browser code reads none of them.
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 actualVersion.prerelease.localeCompare(requiredVersion.prerelease, 'en', {
365
- numeric: true,
366
- }) >= 0
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 values = parseEnv(
1256
- await readFile(path.join(root, '.env.example'), 'utf8'),
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
- ['ServerConfig', 'WorkEnvelope', 'WorkDetailClient', 'WorkService'],
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 configuration. Copy this file to .env.development and replace the credential placeholders.
2
- # The browser calls only same-origin /api/work/{resourceCode}; never rename these values to VITE_*.
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
@@ -1,6 +1,8 @@
1
1
  node_modules/
2
2
  dist/
3
+ /release/
3
4
  .env*
4
5
  !.env.example
6
+ .realsee/
5
7
  *.log
6
8
  .DS_Store
@@ -1,5 +1,7 @@
1
1
  node_modules/
2
2
  dist/
3
+ release/
4
+ .tmp-*
3
5
  .agents/
4
6
  .claude/
5
7
  .cursor/
@@ -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/xZjYOkRkWIz75RwI3x (replace with an authorized resourceCode).
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, so the browser uses only the same-origin `GET /api/work/{resourceCode}` contract. Port 8087 is non-privileged; do not change it to privileged port 517.
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 depends only on `GET /api/work/{resourceCode}`. Fastify is an example implementation, not an application architecture requirement:
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 `REALSEE_GATEWAY_APP_SECRET`; `APP_ID` and `REALSEE_SOURCE` remain optional deployment metadata. Real values belong in `.env.development`, the deployment environment or a secret manager.
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 every direct dependency except `three` as an open minimum range (`>=baseline`). The committed `package-lock.json` records the generator's reviewed initial resolution, so `npm ci` remains reproducible while an explicit update can move across major versions.
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
- Only one dependency is hard-locked:
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
 
@@ -0,0 +1,11 @@
1
+ {
2
+ "packageFormat": "node-vite-bff-v1",
3
+ "name": "{{PACKAGE_NAME}}",
4
+ "requiredEnv": ["REALSEE_GATEWAY_APP_KEY", "REALSEE_GATEWAY_APP_SECRET"],
5
+ "healthCheck": {
6
+ "enabled": true,
7
+ "path": "/api/health",
8
+ "timeoutMs": 2500,
9
+ "retries": 15
10
+ }
11
+ }
@@ -10,6 +10,8 @@ export default tseslint.config(
10
10
  ignores: [
11
11
  'node_modules/**',
12
12
  'dist/**',
13
+ 'release/**',
14
+ '.tmp-*',
13
15
  '.agents/**',
14
16
  '.claude/**',
15
17
  '.cursor/**',