@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.
@@ -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 onboarding `GET /api/work` and return `{ work, workCode, baseURL?, capabilities?: { modelFloorplan } }` without exposing credentials or tokens. The explicit resource-code route is development-only compatibility.
9
- - Use the single `.env.example` as the server/deployment schema. `npm run setup` writes AK, SK and the default `resourceCode` to `.env.development`; only the BFF reads `REALSEE_GATEWAY_ENDPOINT`, `REALSEE_GATEWAY_APP_KEY`, `REALSEE_GATEWAY_APP_SECRET` and `REALSEE_DEFAULT_RESOURCE_CODE`. 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,14 +39,22 @@ 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',
45
52
  'REALSEE_GATEWAY_APP_KEY',
46
53
  'REALSEE_GATEWAY_APP_SECRET',
47
- 'REALSEE_DEFAULT_RESOURCE_CODE',
48
54
  'REALSEE_SOURCE',
49
55
  ]
56
+ const optionalEnvExampleMarker =
57
+ '# REALSEE_DEFAULT_RESOURCE_CODE=example-resource-code'
50
58
  const credentialEnvKeys = [
51
59
  'REALSEE_GATEWAY_APP_KEY',
52
60
  'REALSEE_GATEWAY_APP_SECRET',
@@ -55,6 +63,7 @@ const secretEnvKeys = ['REALSEE_GATEWAY_APP_SECRET']
55
63
  const requiredFiles = [
56
64
  'package.json',
57
65
  'package-lock.json',
66
+ 'deploy.json',
58
67
  'README.md',
59
68
  'index.html',
60
69
  'tsconfig.json',
@@ -68,9 +77,12 @@ const requiredFiles = [
68
77
  '.vr-cli.json',
69
78
  'eslint.config.mjs',
70
79
  'prettier.config.mjs',
80
+ 'scripts/package-vibe-release.mjs',
71
81
  'scripts/setup-config.mjs',
72
82
  'scripts/setup-config.test.mjs',
73
83
  'scripts/setup.mjs',
84
+ 'scripts/pairing.mjs',
85
+ 'scripts/pairing.test.mjs',
74
86
  'server/types.ts',
75
87
  'server/config.ts',
76
88
  'server/check-config.ts',
@@ -365,9 +377,11 @@ function versionAtLeast(actual, required) {
365
377
  if (actualVersion.prerelease === requiredVersion.prerelease) return true
366
378
  if (actualVersion.prerelease === null) return true
367
379
  if (requiredVersion.prerelease === null) return false
368
- return actualVersion.prerelease.localeCompare(requiredVersion.prerelease, 'en', {
369
- numeric: true,
370
- }) >= 0
380
+ return (
381
+ actualVersion.prerelease.localeCompare(requiredVersion.prerelease, 'en', {
382
+ numeric: true,
383
+ }) >= 0
384
+ )
371
385
  }
372
386
 
373
387
  async function currentNpmVersion() {
@@ -439,6 +453,11 @@ try {
439
453
  failures.push(`engines.node must equal ${nodeEngine}`)
440
454
  if (pkg.engines?.npm !== npmEngine)
441
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
+ }
442
461
  for (const [declared, baseline] of [
443
462
  [pkg.dependencies, expected],
444
463
  [pkg.devDependencies, expectedDev],
@@ -482,7 +501,16 @@ try {
482
501
  for (const [name, markers] of [
483
502
  ['dev', ['check:env', 'concurrently']],
484
503
  ['setup', ['scripts/setup.mjs']],
485
- ['test:setup', ['--test', 'scripts/setup-config.test.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
+ ],
486
514
  ['dev:bff', ['--env-file-if-exists=.env.development', 'server/index.ts']],
487
515
  ['dev:web', ['vite']],
488
516
  [
@@ -499,6 +527,7 @@ try {
499
527
  ['check:bff', ['typecheck:bff']],
500
528
  ['deps:relock', ['npm install --package-lock-only']],
501
529
  ['deps:install', ['npm ci']],
530
+ ['package:vibe-release', ['node scripts/package-vibe-release.mjs']],
502
531
  [
503
532
  'start',
504
533
  [
@@ -532,13 +561,17 @@ try {
532
561
  'package-lock.json root name/version must match package.json',
533
562
  )
534
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
+ }
535
573
  for (const [lockDeclared, declared, baseline, label] of [
536
- [
537
- lockRoot.dependencies,
538
- pkg.dependencies,
539
- expected,
540
- 'dependencies',
541
- ],
574
+ [lockRoot.dependencies, pkg.dependencies, expected, 'dependencies'],
542
575
  [
543
576
  lockRoot.devDependencies,
544
577
  pkg.devDependencies,
@@ -588,6 +621,123 @@ try {
588
621
  failures.push(`package metadata: ${String(error)}`)
589
622
  }
590
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
+
591
741
  for (const file of requiredFiles) {
592
742
  if (!(await exists(file))) failures.push(`missing ${file}`)
593
743
  }
@@ -1260,12 +1410,21 @@ if (await exists('src/styles.css')) {
1260
1410
  }
1261
1411
 
1262
1412
  if (await exists('.env.example')) {
1263
- const values = parseEnv(
1264
- await readFile(path.join(root, '.env.example'), 'utf8'),
1265
- )
1413
+ const exampleSource = await readFile(path.join(root, '.env.example'), 'utf8')
1414
+ const values = parseEnv(exampleSource)
1266
1415
  for (const key of requiredEnvKeys) {
1267
1416
  if (!values.has(key)) failures.push(`.env.example missing ${key}`)
1268
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
+ }
1269
1428
  for (const [key, value] of values) {
1270
1429
  if (key.startsWith('VITE_WORK_'))
1271
1430
  failures.push(
@@ -1312,7 +1471,13 @@ if (await exists('.gitignore')) {
1312
1471
  for (const [relativePath, markers] of [
1313
1472
  [
1314
1473
  'server/types.ts',
1315
- ['ServerConfig', 'WorkEnvelope', 'WorkDetailClient', 'WorkService'],
1474
+ [
1475
+ 'ServerConfig',
1476
+ 'WorkEnvelope',
1477
+ 'WorkDetailClient',
1478
+ 'WorkService',
1479
+ 'defaultResourceCode: string | undefined',
1480
+ ],
1316
1481
  ],
1317
1482
  [
1318
1483
  'server/config.ts',
@@ -1321,6 +1486,9 @@ for (const [relativePath, markers] of [
1321
1486
  'REALSEE_GATEWAY_APP_KEY',
1322
1487
  'REALSEE_GATEWAY_APP_SECRET',
1323
1488
  'REALSEE_DEFAULT_RESOURCE_CODE',
1489
+ 'optionalResourceCode',
1490
+ 'if (!nonEmptyString(value)) return undefined',
1491
+ '^[A-Za-z0-9_-]{1,128}$',
1324
1492
  "gatewayEndpoint.protocol !== 'https:'",
1325
1493
  '): ServerConfig {',
1326
1494
  ],
@@ -1357,6 +1525,8 @@ for (const [relativePath, markers] of [
1357
1525
  [
1358
1526
  "'/api/work'",
1359
1527
  "'/api/work/:resourceCode'",
1528
+ 'defaultResourceCode: string | undefined',
1529
+ "error: 'RESOURCE_CODE_REQUIRED'",
1360
1530
  'getWorkByResourceCode(resourceCode, {',
1361
1531
  'signal: requestAbort.signal',
1362
1532
  'WORK_UPSTREAM_FAILED',
@@ -1367,6 +1537,7 @@ for (const [relativePath, markers] of [
1367
1537
  [
1368
1538
  'Fastify({ logger })',
1369
1539
  "'/api/health'",
1540
+ 'defaultResourceCode: string | undefined',
1370
1541
  'fastifyStatic',
1371
1542
  'serveFrontend',
1372
1543
  "reply.sendFile('index.html')",
@@ -8,7 +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
- # 首次运行时默认加载的 VR ID(resourceCode)
12
- REALSEE_DEFAULT_RESOURCE_CODE=*
11
+ # 可选的根路由 fallback VR ID(resourceCode);未配置时请使用 /{resourceCode}
12
+ # REALSEE_DEFAULT_RESOURCE_CODE=example-resource-code
13
13
  # Realsee 的企业账号 ID(可选部署元数据)
14
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/
@@ -14,14 +14,27 @@ Use Node.js `22.23.2` LTS from `.nvmrc` and `.node-version`: it is the current s
14
14
  npm ci
15
15
  npm run setup
16
16
  npm run dev
17
- # Open http://127.0.0.1:8087/.
17
+ # Open http://127.0.0.1:8087/{resourceCode}.
18
18
  ```
19
19
 
20
- `npm run setup` accepts exactly one versioned single-line payload copied from the Realsee China Portal onboarding page. Paste it only into the hidden interactive prompt. The payload contains AK, SK and the selected `resourceCode`; 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 gitignored server-only `.env.development` atomically with `0600` on POSIX or a current-user ACL on Windows.
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.
21
34
 
22
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.
23
36
 
24
- 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 onboarding uses the same-origin `GET /api/work` contract backed by `REALSEE_DEFAULT_RESOURCE_CODE`. The explicit `GET /api/work/{resourceCode}` and `/{resourceCode}` paths remain development-only compatibility routes. 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.
25
38
 
26
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).
27
40
 
@@ -32,9 +45,21 @@ npm run build
32
45
  npm start
33
46
  ```
34
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
+
35
60
  ## Replace the example BFF
36
61
 
37
- The browser onboarding path depends on `GET /api/work`; 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:
38
63
 
39
64
  - `server/types.ts` declares the server-side contracts (`ServerConfig`, `WorkEnvelope`, `WorkDetailClient`, `WorkService`);
40
65
  - `server/realsee-open-api.ts` owns Realsee token exchange, expiry caching and upstream Work calls;
@@ -51,7 +76,7 @@ The generated project has one `.env.example`. Only the Fastify BFF reads `REALSE
51
76
 
52
77
  ## Update dependencies
53
78
 
54
- 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.
55
80
 
56
81
  To update every unlocked dependency to the newest release allowed by its open range:
57
82
 
@@ -71,9 +96,10 @@ npm run build
71
96
 
72
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.
73
98
 
74
- Only one dependency is hard-locked:
99
+ Two dependencies are exact-pinned for different boundaries:
75
100
 
76
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.
77
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.
78
104
  - The lockfile's resolved direct version must satisfy both the declared minimum and the generator baseline. Raising a minimum requires relocking.
79
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/**',