@volter/editor-sdk 0.5.57

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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +19 -0
  4. package/package.json +90 -0
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +83 -0
  7. package/src/client.ts +1547 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +985 -0
  10. package/src/document-probe.ts +237 -0
  11. package/src/editor-view.ts +220 -0
  12. package/src/extension.ts +40 -0
  13. package/src/generations.ts +178 -0
  14. package/src/host.ts +1167 -0
  15. package/src/http-transport.browser.ts +14 -0
  16. package/src/http-transport.node.ts +19 -0
  17. package/src/index.ts +128 -0
  18. package/src/layout-arrangements.ts +5 -0
  19. package/src/layouts.tsx +108 -0
  20. package/src/looks.ts +14 -0
  21. package/src/project/output-roots.ts +73 -0
  22. package/src/project/tab-census.ts +149 -0
  23. package/src/project-tool-catalog.ts +96 -0
  24. package/src/selection.tsx +108 -0
  25. package/src/services.ts +18 -0
  26. package/src/session/build-report.ts +19 -0
  27. package/src/session/collaboration-types.ts +262 -0
  28. package/src/session/command-table.ts +333 -0
  29. package/src/session/discovery.ts +90 -0
  30. package/src/session/editor-brand.ts +73 -0
  31. package/src/session/editor-compatibility.ts +248 -0
  32. package/src/session/editor-control-lifecycle.ts +68 -0
  33. package/src/session/editor-control-protocol.ts +5 -0
  34. package/src/session/entrypoint-selection-readers.ts +66 -0
  35. package/src/session/entrypoint-selection-source.ts +120 -0
  36. package/src/session/game-css-scope.ts +30 -0
  37. package/src/session/product-create.ts +24 -0
  38. package/src/session/product-locator.ts +389 -0
  39. package/src/session/project-module-url.ts +245 -0
  40. package/src/session/registry-format.ts +203 -0
  41. package/src/session/relative-path-guard.ts +56 -0
  42. package/src/session/source-glob.ts +15 -0
  43. package/src/session/tool-contribution-convention.ts +116 -0
  44. package/src/session/workbench-locator.ts +650 -0
  45. package/src/session.ts +41 -0
  46. package/src/share.ts +160 -0
  47. package/src/tools/errors.ts +91 -0
  48. package/src/tools/provider-execution.ts +70 -0
  49. package/src/tools/registry.ts +341 -0
  50. package/src/tools/types.ts +159 -0
  51. package/src/transport.ts +97 -0
  52. package/src/types.ts +1581 -0
  53. package/src/views.ts +164 -0
  54. package/src/widgets/design-system.ts +93 -0
  55. package/src/widgets/editor-appearance.ts +149 -0
  56. package/src/widgets/editor-material.ts +83 -0
  57. package/src/widgets/icon-set-registry.ts +105 -0
  58. package/src/widgets/index.ts +71 -0
  59. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  60. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  61. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  62. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  63. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  64. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  65. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  66. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  67. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  68. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  69. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  70. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  71. package/src/widgets/inspector-widgets/index.ts +24 -0
  72. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  73. package/src/widgets/interactive-edit-scope.ts +33 -0
  74. package/src/widgets/patterns/Dialog.tsx +129 -0
  75. package/src/widgets/patterns/Fields.tsx +44 -0
  76. package/src/widgets/patterns/List.tsx +25 -0
  77. package/src/widgets/patterns/StateSurface.tsx +40 -0
  78. package/src/widgets/patterns/Surfaces.tsx +122 -0
  79. package/src/widgets/patterns/Tabs.tsx +80 -0
  80. package/src/widgets/patterns/Toolbar.tsx +72 -0
  81. package/src/widgets/patterns/Tree.tsx +72 -0
  82. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  83. package/src/widgets/primitives/Button.tsx +62 -0
  84. package/src/widgets/primitives/ColorInput.tsx +78 -0
  85. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  86. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  87. package/src/widgets/primitives/FormControls.tsx +88 -0
  88. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  89. package/src/widgets/primitives/JsonInput.tsx +113 -0
  90. package/src/widgets/primitives/Layout.tsx +100 -0
  91. package/src/widgets/primitives/Menu.tsx +140 -0
  92. package/src/widgets/primitives/NumberInput.tsx +169 -0
  93. package/src/widgets/primitives/Panel.tsx +80 -0
  94. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  95. package/src/widgets/primitives/Text.tsx +54 -0
  96. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  97. package/src/widgets/primitives/Tooltip.tsx +204 -0
  98. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  99. package/src/widgets/primitives/banner-tones.ts +32 -0
  100. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  101. package/src/widgets/primitives/editor-icons.ts +245 -0
  102. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  103. package/src/widgets/theme.ts +2633 -0
  104. package/src/widgets/z-index.ts +25 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * WHICH PRODUCT OPENS THIS PROJECT — one declaration, one resolver.
3
+ *
4
+ * A PRODUCT is the running program (ARCHITECTURE-CORE §The target shape, rule
5
+ * 4): `@vgai/game-editor` and `@vgai/model-editor` each stitch packages onto
6
+ * the editor kit in code and each carry a `bin`. **Which product runs is never
7
+ * a switch** — there is no `--product` flag, no id in the manifest, no setting,
8
+ * the same way a folder never names which VS Code opens it and a `.blend` never
9
+ * names Blender.
10
+ *
11
+ * So the resolution is a DEPENDENCY resolution, exactly as
12
+ * `assertEditorProjectInstalled` resolves `@vgai/editor`: the project's
13
+ * `dependencies` ∪ `devDependencies` are read, each `@vgai/*` among them is
14
+ * resolved from the project, and the one whose `package.json` carries
15
+ * `vgai.product` is the product. A project's `package.json` naming a product as
16
+ * a devDependency is the person's convenience — what `npm run dev` launches —
17
+ * and not a rule about the project.
18
+ *
19
+ * NONE and TWO are both refusals by name, because either answer would otherwise
20
+ * be guessed. None prints the `npm install --save-dev` line for both products;
21
+ * two names both and says the project must resolve it, because a page runs one
22
+ * product and nothing here can pick.
23
+ *
24
+ * WHO CALLS THIS, and why twice. The CLI resolves to REFUSE EARLY and to hand
25
+ * the directory to the session; the session resolves to SERVE the product's
26
+ * entry and to REPORT it (`EditorState.product`), because what it is serving is
27
+ * its own fact. One function, one set of refusal texts.
28
+ */
29
+
30
+ import { existsSync, readFileSync } from 'node:fs';
31
+ import { createRequire } from 'node:module';
32
+ import { dirname, join } from 'node:path';
33
+ import { pathToFileURL } from 'node:url';
34
+
35
+ /** The manifest key a product declares. A DECLARATION, not a config: it says
36
+ * what this package IS so the CLI and the session can find its entry without
37
+ * running it (ARCHITECTURE-CORE §The target shape, rule 8). */
38
+ export const PRODUCT_DECLARATION_KEY = 'vgai.product';
39
+
40
+ /** Light or dark — the two the workbench's own `ColorScheme` has plain names
41
+ * for, and the only two a product needs to state (a high-contrast scheme is a
42
+ * person's accessibility choice, never a product's declaration). */
43
+ export type ProductColorTheme = 'dark' | 'light';
44
+
45
+ /**
46
+ * THE WORKBENCH THIS PRODUCT IS, named as a published release.
47
+ *
48
+ * A product is the running program, and the program is the Code-OSS workbench
49
+ * its kit and its half were compiled INTO (ARCHITECTURE-CORE §The target
50
+ * shape: "one product number per release, pinning the fork, the kit, the API
51
+ * and its media"). So which release those bytes are is not a machine's setting
52
+ * and not a person's choice — it is what the product IS, declared beside
53
+ * `entry` and `colorTheme` and read without running anything (rule 8).
54
+ *
55
+ * `release` is the tag `scripts/workbench/build-release.mjs --publish` cuts on
56
+ * the fork's own repository, `<product>-<fork sha 12>-<platform>`;
57
+ * `tarballSha256` is the pin, verified against the downloaded bytes before a
58
+ * single one of them is extracted. Both come out of that script's own
59
+ * `BUILD.json`, and it prints this block to paste.
60
+ *
61
+ * A machine that has already fetched it keeps it in `~/.vgai/workbenches/<tag>`
62
+ * and a project that has one names it in `.vgai/workbench.json`, so this is
63
+ * consulted exactly once per machine per release — see
64
+ * `workbench-locator.ts`'s `resolveWorkbenchForProject`.
65
+ */
66
+ export interface ProductWorkbench {
67
+ /** The GitHub Release tag on `volter-ai/code-oss`. */
68
+ readonly release: string;
69
+ /** The sha256 of that release's tarball asset, hex. */
70
+ readonly tarballSha256: string;
71
+ }
72
+
73
+ /** A product package, as everything outside it needs to know it. */
74
+ export interface ProductIdentity {
75
+ /** The package name — `@vgai/game-editor`. */
76
+ readonly name: string;
77
+ /** Its version, reported beside the workbench by `vgai status`. */
78
+ readonly version: string;
79
+ /** Its package root, absolute. */
80
+ readonly dir: string;
81
+ /**
82
+ * Its ONE source entry, package-relative and POSIX (`./src/index.ts`). That
83
+ * entry IS the product's frame entry: it composes and re-exports `mountVgai`,
84
+ * which is the name the fork's contribution reads off the served module.
85
+ */
86
+ readonly entry: string;
87
+ /**
88
+ * WHICH WAY ROUND THIS PRODUCT PAINTS, before one line of its code runs.
89
+ *
90
+ * Measured 2026-09-21 on both releases: the web workbench's FIRST PAINT is
91
+ * LIGHT (`workbenchThemeService.ts`, `this.settings.getPreferredColorScheme()
92
+ * ?? (isWeb ? ColorScheme.LIGHT : ColorScheme.DARK)`), so the product's
93
+ * opening cover — which paints in `--vscode-*` variables, as it must — was
94
+ * white for its whole life and flipped dark the instant the real theme
95
+ * landed. The supported door is `IWorkbenchConstructionOptions
96
+ * .initialColorTheme`, which is read out of the PAGE CONFIG, before any
97
+ * extension or setting is available; the session's proxy injects it
98
+ * (`packages/editor/server/frame-proxy.ts`).
99
+ *
100
+ * It is a DECLARATION and not a setting (ARCHITECTURE-CORE §The target
101
+ * shape, rule 8): it says what the product IS, the same way `entry` does, and
102
+ * it has to be readable without running the product because the proxy needs
103
+ * it before the product exists. A person's own `workbench.colorTheme` still
104
+ * wins on every open after the first — the workbench prefers its stored theme
105
+ * over this and only falls back here when it has none.
106
+ */
107
+ readonly colorTheme: ProductColorTheme;
108
+ /** WHICH WORKBENCH THIS PRODUCT IS — see {@link ProductWorkbench}. */
109
+ readonly workbench: ProductWorkbench | null;
110
+ }
111
+
112
+ /** The install line a refusal quotes, for a project that already EXISTS. Both
113
+ * products, because a person choosing between them is choosing what they are
114
+ * building, not a flag. */
115
+ export const PRODUCT_INSTALL_LINES = [' npm install --save-dev @volter/editor'] as const;
116
+
117
+ /**
118
+ * The create line a refusal or a help screen quotes, for a project that does
119
+ * not exist yet. CREATION IS THE PRODUCT'S (ARCHITECTURE-CORE §The target
120
+ * shape, rule 1): a product scaffolds the project it opens, declaring its own
121
+ * presets, and there is no create verb in the kit's CLI for either of them to
122
+ * be an alias of.
123
+ *
124
+ * This is the ONE place the two product names are spelled outside the products
125
+ * themselves; the CLI and the session quote it rather than keeping a copy.
126
+ */
127
+ export const PRODUCT_CREATE_LINES = [' npx @volter/editor create <name>'] as const;
128
+
129
+ interface ProductManifestShape {
130
+ private?: unknown;
131
+ name?: unknown;
132
+ version?: unknown;
133
+ vgai?: { product?: { entry?: unknown; colorTheme?: unknown; workbench?: unknown } };
134
+ }
135
+
136
+ const PRODUCT_COLOR_THEMES: readonly ProductColorTheme[] = ['dark', 'light'];
137
+
138
+ /**
139
+ * Read one product package's declaration, or `null` when the manifest is not a
140
+ * product's. Throws when it declares `vgai.product` and the declaration is
141
+ * unusable — a half-written product must be named, never skipped.
142
+ */
143
+ export function readProductManifest(packageDir: string): ProductIdentity | null {
144
+ const manifestPath = join(packageDir, 'package.json');
145
+ let manifest: ProductManifestShape;
146
+ try {
147
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ProductManifestShape;
148
+ } catch {
149
+ return null;
150
+ }
151
+ const declared = manifest.vgai?.product;
152
+ if (declared === undefined) return null;
153
+ const entry = declared.entry;
154
+ if (typeof entry !== 'string' || !entry.startsWith('./'))
155
+ throw new Error(
156
+ `${manifestPath} declares ${PRODUCT_DECLARATION_KEY} but its "entry" is ` +
157
+ `${JSON.stringify(entry)}. It must be a ./-relative module — the product's ONE source ` +
158
+ 'entry, which is also its frame entry (e.g. "./src/index.ts").',
159
+ );
160
+ const resolved = join(packageDir, entry);
161
+ if (!existsSync(resolved))
162
+ throw new Error(
163
+ `${manifestPath} declares ${PRODUCT_DECLARATION_KEY}.entry ${JSON.stringify(entry)}, ` +
164
+ `which does not exist (${resolved}).`,
165
+ );
166
+ const colorTheme = declared.colorTheme;
167
+ // REQUIRED, like `entry`, and for the same reason: a product that does not
168
+ // say which way round it paints gets the workbench's web default, which is
169
+ // LIGHT, and its cover is white until its real theme loads. There is no
170
+ // defensible default to invent here — a dark product and a light product are
171
+ // both ordinary — so an unstated one is named rather than guessed.
172
+ if (typeof colorTheme !== 'string' || !PRODUCT_COLOR_THEMES.includes(colorTheme as never))
173
+ throw new Error(
174
+ `${manifestPath} declares ${PRODUCT_DECLARATION_KEY} but its "colorTheme" is ` +
175
+ `${JSON.stringify(colorTheme)}. It must be ${PRODUCT_COLOR_THEMES.map((one) =>
176
+ JSON.stringify(one),
177
+ ).join(' or ')} — which way round this product paints, read before any of its code ` +
178
+ "runs so the workbench's first frame is already the product's own background.",
179
+ );
180
+ return {
181
+ name: typeof manifest.name === 'string' ? manifest.name : packageDir,
182
+ version: typeof manifest.version === 'string' ? manifest.version : 'unknown',
183
+ dir: packageDir,
184
+ entry,
185
+ colorTheme: colorTheme as ProductColorTheme,
186
+ // A private source product may use an explicitly selected matching checkout.
187
+ // Public packages must always carry a downloadable, checksummed workbench.
188
+ workbench: manifest.private === true && declared.workbench === undefined
189
+ ? null : readProductWorkbench(manifestPath, declared.workbench),
190
+ };
191
+ }
192
+
193
+ /** The sha256 a release pins its tarball with, as `BUILD.json` writes it. */
194
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
195
+
196
+ /**
197
+ * REQUIRED, like `entry` and `colorTheme`, and for the sharpest reason of the
198
+ * three: a product that does not say which workbench it is cannot be INSTALLED
199
+ * — `npx @vgai/<product> create <name>` on a machine with nothing else on it
200
+ * has no other way to find the bytes it runs in, and the person is left writing
201
+ * a path to a directory they would have to build first. There is no default to
202
+ * invent (the tag names a fork commit nobody can guess), so an unstated one is
203
+ * named rather than guessed.
204
+ */
205
+ function readProductWorkbench(manifestPath: string, declared: unknown): ProductWorkbench {
206
+ const example =
207
+ ' "vgai": { "product": { "workbench": { "release": "<product>-<fork sha 12>-<platform>", ' +
208
+ '"tarballSha256": "<64 hex>" } } }\n' +
209
+ '`node scripts/workbench/build-release.mjs --publish --out <release dir>` cuts the release ' +
210
+ 'and prints exactly that block.';
211
+ if (typeof declared !== 'object' || declared === null || Array.isArray(declared))
212
+ throw new Error(
213
+ `${manifestPath} declares ${PRODUCT_DECLARATION_KEY} but its "workbench" is ` +
214
+ `${JSON.stringify(declared)}. A product IS the Code-OSS workbench it was compiled into, ` +
215
+ `so it names the published release those bytes are:\n${example}`,
216
+ );
217
+ const record = declared as Record<string, unknown>;
218
+ const extra = Object.keys(record).filter((key) => key !== 'release' && key !== 'tarballSha256');
219
+ if (extra.length > 0)
220
+ throw new Error(
221
+ `${manifestPath}'s ${PRODUCT_DECLARATION_KEY}.workbench carries ` +
222
+ `${extra.map((key) => `"${key}"`).join(', ')}, which this declaration has no meaning for. ` +
223
+ `It names the release and the pin, and nothing else:\n${example}`,
224
+ );
225
+ const release = record['release'];
226
+ if (typeof release !== 'string' || release.trim() === '')
227
+ throw new Error(
228
+ `${manifestPath}'s ${PRODUCT_DECLARATION_KEY}.workbench names no "release" — the GitHub ` +
229
+ `Release tag its workbench was published under:\n${example}`,
230
+ );
231
+ const tarballSha256 = record['tarballSha256'];
232
+ if (typeof tarballSha256 !== 'string' || !SHA256_HEX.test(tarballSha256))
233
+ throw new Error(
234
+ `${manifestPath}'s ${PRODUCT_DECLARATION_KEY}.workbench has tarballSha256 ` +
235
+ `${JSON.stringify(tarballSha256)}, which is not 64 hex characters. It is the PIN — the ` +
236
+ 'hash the downloaded tarball is checked against before a byte of it is extracted, so it ' +
237
+ `is never approximate:\n${example}`,
238
+ );
239
+ return { release, tarballSha256 };
240
+ }
241
+
242
+ /**
243
+ * A product's SHORT id — `@vgai/model-editor` → `model-editor`.
244
+ *
245
+ * It is the product's own `product({ id })`, the directory its workbench half
246
+ * lives in (`packages/<id>/workbench`), the `--product` flag the overlay and the
247
+ * release build take, and what a release records in `BUILD.json`. One derivation
248
+ * rather than four literals: the package name is the only place the product is
249
+ * NAMED, and everything else is that name's last segment.
250
+ */
251
+ export function workbenchProductId(packageName: string): string {
252
+ return packageName.split('/').pop() ?? packageName;
253
+ }
254
+
255
+ /** A product's entry as a path relative to `fromDir`, POSIX — the spelling a
256
+ * Vite manifest key and a dev-server URL both use. */
257
+ export function productEntryPath(product: ProductIdentity, fromDir: string): string {
258
+ const absolute = join(product.dir, product.entry);
259
+ const prefix = fromDir.endsWith('/') ? fromDir : `${fromDir}/`;
260
+ const relative = absolute.startsWith(prefix) ? absolute.slice(prefix.length) : absolute;
261
+ return relative.split('\\').join('/');
262
+ }
263
+
264
+ /** Every `@vgai/*` this project declares, in either section, sorted. */
265
+ function declaredVgaiPackages(projectRoot: string): string[] {
266
+ let manifest: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
267
+ try {
268
+ manifest = JSON.parse(
269
+ readFileSync(join(projectRoot, 'package.json'), 'utf8'),
270
+ ) as typeof manifest;
271
+ } catch {
272
+ return [];
273
+ }
274
+ return Object.keys({ ...(manifest.dependencies ?? {}), ...(manifest.devDependencies ?? {}) })
275
+ .filter((name) => name.startsWith('@volter/'))
276
+ .sort();
277
+ }
278
+
279
+ /** Where a package resolves FROM THE PROJECT, or null when it is not installed. */
280
+ function locatePackage(projectRoot: string, name: string): string | null {
281
+ const require = createRequire(pathToFileURL(join(projectRoot, 'package.json')));
282
+ try {
283
+ return dirname(require.resolve(`${name}/package.json`));
284
+ } catch {
285
+ // No `./package.json` export: walk up from whatever the bare name resolves
286
+ // to, which is what a package with a strict export map leaves reachable.
287
+ try {
288
+ let dir = dirname(require.resolve(name));
289
+ for (;;) {
290
+ const candidate = join(dir, 'package.json');
291
+ try {
292
+ if ((JSON.parse(readFileSync(candidate, 'utf8')) as { name?: string }).name === name)
293
+ return dir;
294
+ } catch {
295
+ /* keep walking */
296
+ }
297
+ const parent = dirname(dir);
298
+ if (parent === dir) return null;
299
+ dir = parent;
300
+ }
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * THE PACKAGES A PRODUCT COMPOSES — its `dependencies` that declare
309
+ * `package.json#vgai.contributions`, resolved from the product's own install.
310
+ *
311
+ * COMPOSITIONS ARE CODE (ARCHITECTURE-CORE §The target shape, rule 8): the
312
+ * product's entry names each of them as `vgai:contributions/<name>`, and that
313
+ * import IS the composition. This function does not read the entry — it reads
314
+ * the MIRROR of it in the manifest, which `scripts/validate-package-estate.mjs`
315
+ * keeps equal to the source by scanning those same specifiers and writing the
316
+ * dependency line. Two readings of one fact, one of them checked, and neither
317
+ * of them a second list somebody maintains: a package the entry imports and the
318
+ * manifest omits fails the gate, and a dependency the entry does not import is
319
+ * a library the product uses rather than a composition it mounts (that is what
320
+ * "declares contributions" tests).
321
+ *
322
+ * The runtime reads the manifest rather than the source because it is the cheap
323
+ * and exact half: a `package.json` is JSON this process already parses, and a
324
+ * source scan in the session would be a second implementation of the gate's.
325
+ */
326
+ export function productComposedPackages(product: ProductIdentity): string[] {
327
+ let manifest: { dependencies?: Record<string, string> };
328
+ try {
329
+ manifest = JSON.parse(
330
+ readFileSync(join(product.dir, 'package.json'), 'utf8'),
331
+ ) as typeof manifest;
332
+ } catch {
333
+ return [];
334
+ }
335
+ const found: string[] = [];
336
+ for (const name of Object.keys(manifest.dependencies ?? {}).sort()) {
337
+ const dir = locatePackage(product.dir, name);
338
+ if (dir === null) continue;
339
+ let dependency: { vgai?: { contributions?: unknown } };
340
+ try {
341
+ dependency = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as typeof dependency;
342
+ } catch {
343
+ continue;
344
+ }
345
+ if (Array.isArray(dependency.vgai?.contributions)) found.push(name);
346
+ }
347
+ return found;
348
+ }
349
+
350
+ /** The products this project declares AND resolves, in declaration order. */
351
+ export function declaredProducts(projectRoot: string): ProductIdentity[] {
352
+ const found: ProductIdentity[] = [];
353
+ for (const name of declaredVgaiPackages(projectRoot)) {
354
+ const dir = locatePackage(projectRoot, name);
355
+ if (dir === null) continue;
356
+ const product = readProductManifest(dir);
357
+ if (product !== null) found.push(product);
358
+ }
359
+ return found;
360
+ }
361
+
362
+ /**
363
+ * THE product this project opens in, or a throw naming what is wrong.
364
+ *
365
+ * The refusals are the whole point: a project with none and a project with two
366
+ * are both states a person can be in, and neither has an answer this code may
367
+ * invent.
368
+ */
369
+ export function resolveProductForProject(projectRoot: string): ProductIdentity {
370
+ const products = declaredProducts(projectRoot);
371
+ if (products.length === 1) return products[0] as ProductIdentity;
372
+ if (products.length === 0)
373
+ throw new Error(
374
+ `${projectRoot} declares no editor product, so there is no editor to open it in.\n` +
375
+ 'The installed product supplies the editor,\n' +
376
+ 'and which one runs is what the project installed, never a flag.\n' +
377
+ 'For THIS folder, install the one you are building with:\n' +
378
+ `${PRODUCT_INSTALL_LINES.join('\n')}\n` +
379
+ 'then run `volter-editor edit` again. For a NEW project, the product creates it:\n' +
380
+ `${PRODUCT_CREATE_LINES.join('\n')}`,
381
+ );
382
+ throw new Error(
383
+ `${projectRoot} declares ${products.length} editor products — ` +
384
+ `${products.map((product) => product.name).join(' and ')}.\n` +
385
+ 'One page runs one product, and nothing here may choose between them. Remove the one you\n' +
386
+ 'are not building with:\n' +
387
+ products.map((product) => ` npm uninstall ${product.name}`).join('\n'),
388
+ );
389
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The ONE owner of every dev-server import URL the editor builds for a
3
+ * PROJECT-OWNED module (a root's `entry`, a `{ module }` adapter's module).
4
+ *
5
+ * WHY THIS MODULE EXISTS: PD-3, a SILENT CROSS-ROOT MODULE SPLIT
6
+ * -------------------------------------------------------------
7
+ * A vgai project declares several adapter roots, and two of them routinely
8
+ * import the SAME project module (the worked reference is a three root
9
+ * writing `arena-state.ts`'s module-level `let snapshot` each frame while a
10
+ * `dom` root's HUD reads it). Browser ES-module identity is per-URL, so the
11
+ * whole model only works while every root resolves that shared module to the
12
+ * BYTE-IDENTICAL url. Measured mechanism (vite 6.4.1, reproduced with a real
13
+ * dev server — see the PR body's probe):
14
+ *
15
+ * 1. The three root's entry was imported with a monotonic cache-buster
16
+ * (`?vgai-play=<n>`) so a remount re-runs edited code. Every mount is
17
+ * therefore a url the browser has never fetched → a fresh transform.
18
+ * 2. The `dom` root's entry was imported at a BARE url with no buster at
19
+ * all. A remount re-issues the identical string, so `import()` resolves
20
+ * straight out of the browser's module registry — the dom root keeps the
21
+ * module graph of whatever mount FIRST evaluated it.
22
+ * 3. Editing a shared module HMR-invalidates it, and vite's import-analysis
23
+ * then appends `?t=<ts>` to it for every importer it re-transforms. So
24
+ * after the very first edit, the freshly-transformed three entry imports
25
+ * `…/arena-state.ts?t=T` while the cached dom entry still holds
26
+ * `…/arena-state.ts` — two urls, two evaluated instances, two copies of
27
+ * `let snapshot`. Nothing errors: the HUD reads its own copy's defaults
28
+ * forever while `game.state()` reports the three root's live one.
29
+ *
30
+ * THE GUARANTEE this module exists to make: every root entry of ONE mount is
31
+ * imported with the SAME mount-scoped buster, so all roots are transformed
32
+ * together and vite hands every one of them the same current `?t=` for every
33
+ * shared dep. `beginProjectMountEpoch()` is called once per composition
34
+ * resolve (`binding-resolver.ts`'s `resolveAllRootAdapters`); every entry url
35
+ * built for that mount reads the same epoch.
36
+ *
37
+ * Do not hand-build a `/@fs/…` url for a project entry anywhere else —
38
+ * `packages/editor/test/project-module-url-single-owner.test.ts` is the
39
+ * tripwire. The runtime backstop for whatever this misses is
40
+ * `project-module-split.ts`, which reports a split LOUDLY instead of letting
41
+ * the next one be silent again.
42
+ */
43
+
44
+ /**
45
+ * Build a `/@fs/<absolute path>` dev-server import URL WITHOUT a doubled
46
+ * leading slash (T6.2 slice 3 finding — see the "Report back" of that
47
+ * slice's dispatch). `projectRoot` is always an absolute path (it starts
48
+ * with `/`), so the naive `` `/@fs/${projectRoot}/...` `` template produces
49
+ * `/@fs//abs/olute/path/...` — cosmetically harmless on its own (Vite's
50
+ * `/@fs/` middleware tolerates it), UNTIL the SAME file is also reached via
51
+ * a DIFFERENT route: a react world's entry module's own STATIC relative
52
+ * import (e.g. `import { useWorldState } from './src/ui/game-
53
+ * state'`) is rewritten by Vite's normal resolver into a SINGLE-slash,
54
+ * symlink-canonicalized `/@fs/` URL — a textually different string than
55
+ * this file's hand-built double-slash one. Browser ES module identity is
56
+ * per-URL, so the two builds of `/@fs/` string for the SAME file produced
57
+ * TWO SEPARATE module instances (two `createContext()` calls for
58
+ * `game-state.tsx`), and `useWorldState`'s `useGame()` threw "no Game in
59
+ * context" even though `<WorldProvider>` genuinely wrapped the entry — this
60
+ * is what `resolveDefaultReactAdapter`'s AC e2e (T6.2 slice 3) caught. The
61
+ * server-side half of this fix is `canonical-path.ts` (canonicalizes
62
+ * `projectRoot` itself so it agrees with Vite's own symlink-resolved form);
63
+ * this half removes the remaining single/double-slash divergence so the
64
+ * two strings are BYTE-IDENTICAL once `projectRoot` is canonical.
65
+ *
66
+ * Lives in a module of its own rather than beside the root resolver so every
67
+ * lane that needs a project import url — the realm's `/@fs` loader, the
68
+ * `{ module }` route, the ingest descriptors — reaches ONE owner without an
69
+ * import cycle. Before that, the three/pixi entry route hand-built a
70
+ * doubled-slash form of its own, which is precisely how one shared module came
71
+ * to be served twice under two urls.
72
+ */
73
+ export function fsImportPath(projectRoot: string, relativePath: string): string {
74
+ const root = projectRoot.endsWith('/') ? projectRoot.slice(0, -1) : projectRoot;
75
+ const rel = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
76
+ // Windows canonical roots ('C:/…') carry no leading slash of their own —
77
+ // without inserting one the URL degenerates to '/@fsC:/…', which Vite
78
+ // serves as a 404. POSIX roots keep the concat form (root already starts
79
+ // with '/'), preserving the byte-identical contract described above.
80
+ return root.startsWith('/') ? `/@fs${root}/${rel}` : `/@fs/${root}/${rel}`;
81
+ }
82
+
83
+ /** Reuse Vite's exact update URL to verify evaluation without a second module
84
+ * instance. Vite emits afterUpdate even when importing the update failed.
85
+ */
86
+ export function viteUpdateImportPath(
87
+ update: {
88
+ acceptedPath: string;
89
+ timestamp: number;
90
+ explicitImportRequired?: boolean;
91
+ },
92
+ base = '/',
93
+ ): string {
94
+ const [file, query] = update.acceptedPath.split('?');
95
+ return (
96
+ base +
97
+ file!.slice(1) +
98
+ `?${update.explicitImportRequired ? 'import&' : ''}t=${update.timestamp}${query ? `&${query}` : ''}`
99
+ );
100
+ }
101
+
102
+ /** The query key every project ENTRY url carries. One name for every root's
103
+ * entry (it replaced the per-caller `?vgai-play=` / `?vgai-reload=` names,
104
+ * whose divergence was never meaningful and whose ABSENCE on the react route
105
+ * was PD-3 itself). */
106
+ export const PROJECT_MOUNT_QUERY = 'vgai-mount';
107
+
108
+ /**
109
+ * The INGEST remount buster — deliberately NOT {@link PROJECT_MOUNT_QUERY}.
110
+ *
111
+ * `vgai-mount` is the whole mount-instance machinery: the server rewrites the
112
+ * module's relative specifiers to carry the same stamp (a per-mount subgraph)
113
+ * and the gated-globals prelude keys the GAME REALM by its value. An ingest
114
+ * remount wants exactly one of those effects — a fresh evaluation of the
115
+ * entry, so its top-level boot re-runs — and none of the others: the ingest
116
+ * lane's readers (the contract shim's `window.vgaiGame`, the live plane, the
117
+ * evidence surface) all read the DEFAULT realm. Busting the ingest entry with
118
+ * `vgai-mount` moved the shim's assignment into a per-epoch realm nobody
119
+ * reads: the game played while `vgai status` said "idle — commands/providers
120
+ * enumerate while playing" and the doctor read "declares no game-state
121
+ * providers" — measured on racing-game, 2026-08-22. A key the server does not
122
+ * interpret busts the URL and changes nothing else.
123
+ */
124
+ export const INGEST_REMOUNT_QUERY = 'vgai-ingest-remount';
125
+
126
+ /**
127
+ * Optional play remount: serve this entry with its swap-slot const rewritten
128
+ * to `key`. Only the targeted region's entry carries these — siblings keep
129
+ * the shared `?vgai-mount=` so PD-3 still holds. Kept in sync with the
130
+ * server plugin's literals by `project-module-instance.test.ts`.
131
+ */
132
+ export const PROJECT_SELECTION_QUERY = 'vgai-selection';
133
+ export const PROJECT_SCENE_QUERY = 'vgai-scene';
134
+
135
+ /** Isolation imports a screen CLASS. The matching server key is
136
+ * `ISOLATE_QUERY_KEY` — tripwired in `project-module-instance.test.ts`. */
137
+ export const PROJECT_ISOLATE_QUERY = 'vgai-isolate';
138
+
139
+ /** Host-owned mount parameter: remount the entrypoint at a declared table key. */
140
+ export interface EntrypointSelectionOverride {
141
+ readonly selection: string;
142
+ readonly key: string;
143
+ }
144
+
145
+ let lastMountEpoch = 0;
146
+
147
+ /**
148
+ * A mount generation. Opaque on purpose: callers thread it, never construct or
149
+ * arithmetic it.
150
+ *
151
+ * It is also the INSTANCE IDENTITY for multi-instance authoring — two mounts
152
+ * at different epochs import every project module at different urls, and
153
+ * browser module identity is per-url, so their module graphs are genuinely
154
+ * separate (separate `let`s, separate class identities). See
155
+ * ARCHITECTURE-CORE §Observability "N INSTANCES of one project". That is the
156
+ * PD-3 guarantee read the other way round: same epoch ⇒ shared modules,
157
+ * different epoch ⇒ nothing shared.
158
+ */
159
+ export type ProjectMountEpoch = number;
160
+
161
+ /**
162
+ * Open a new mount generation. Called ONCE per composition resolve, before any
163
+ * root's entry is imported, so every root of that mount shares one buster (the
164
+ * guarantee in this module's header). Monotonic, so a remount always
165
+ * re-transforms every entry and therefore re-runs edited code.
166
+ *
167
+ * The returned epoch is THREADED to every url built for this mount. It is
168
+ * deliberately not readable from ambient state: a module-level "current epoch"
169
+ * makes isolation SEQUENTIAL — two concurrent resolves interleave their
170
+ * `begin` calls and each then builds urls with the other's generation, so the
171
+ * mounts silently share module instances. Passing the value is what makes N
172
+ * concurrent instances expressible at all.
173
+ */
174
+ export function beginProjectMountEpoch(): ProjectMountEpoch {
175
+ lastMountEpoch += 1;
176
+ return lastMountEpoch;
177
+ }
178
+
179
+ /**
180
+ * The dev-server import url for one root's `entry` (or a `{ module }`
181
+ * adapter's module) — `fsImportPath` plus the mount's own epoch. Two roots
182
+ * resolved in the SAME mount pass the same epoch and therefore get the same
183
+ * query, which is the whole point.
184
+ */
185
+ /**
186
+ * Import a scene CLASS as an Edit piece. The isolate query keys a module
187
+ * graph whose vendor `init();` is silenced — Play still boots the unstamped
188
+ * entry. Cache-busted per mount, same always-fresh discipline as design-time
189
+ * CSF (`stories/story-discovery.ts`).
190
+ */
191
+ export function isolationImportUrl(projectRoot: string, relativePath: string): string {
192
+ return `${fsImportPath(projectRoot, relativePath)}?${PROJECT_ISOLATE_QUERY}=1&t=${Date.now()}`;
193
+ }
194
+
195
+ /**
196
+ * The LIVE MODULE document's import url — a project `.ts`/`.tsx` module opened
197
+ * as an Asset Lab document that re-executes on every save
198
+ * (`components/asset-viewers/LiveModuleDocument.tsx`).
199
+ *
200
+ * Deliberately NOT {@link PROJECT_MOUNT_QUERY}, for the reason
201
+ * {@link INGEST_REMOUNT_QUERY} states: `vgai-mount` is the whole
202
+ * mount-instance machinery (per-mount specifier rewriting, gated-globals
203
+ * realm keying). A model module is EDITOR-side content — it builds an
204
+ * `Object3D` for a document, it does not mount a game — so it wants exactly
205
+ * one of those effects, a fresh evaluation, and none of the others. A key the
206
+ * server does not interpret busts the url and changes nothing else.
207
+ *
208
+ * `revision` is the document's own rebuild counter, so consecutive saves each
209
+ * get a url the browser has never fetched. That is only half the freshness
210
+ * story and the smaller half: the dev server's `hotUpdate` hook has already
211
+ * stamped the changed file HMR-invalid across the project's subgraph
212
+ * (`server/project-script-hmr.ts`), so re-transforming this module also
213
+ * re-points every dependency of it at a fresh `?t=`. Busting the leaf alone
214
+ * would re-run the leaf against stale imports.
215
+ */
216
+ export function liveModuleImportUrl(
217
+ projectRoot: string,
218
+ relativePath: string,
219
+ revision: number,
220
+ ): string {
221
+ return `${fsImportPath(projectRoot, relativePath)}?vgai-live-module=${revision}`;
222
+ }
223
+
224
+ let liveModuleRevision = Date.now();
225
+
226
+ /** A rebuild's import identity must outlive a component's local counter.
227
+ * Closing/reopening a document must never reuse its first cached module. */
228
+ export function beginLiveModuleRevision(): number {
229
+ liveModuleRevision = Math.max(liveModuleRevision + 1, Date.now());
230
+ return liveModuleRevision;
231
+ }
232
+
233
+ export function projectEntryImportUrl(
234
+ projectRoot: string,
235
+ entry: string,
236
+ epoch: ProjectMountEpoch,
237
+ selectionOverride?: EntrypointSelectionOverride,
238
+ ): string {
239
+ const base = `${fsImportPath(projectRoot, entry)}?${PROJECT_MOUNT_QUERY}=${epoch}`;
240
+ if (!selectionOverride) return base;
241
+ return (
242
+ `${base}&${PROJECT_SELECTION_QUERY}=${encodeURIComponent(selectionOverride.selection)}` +
243
+ `&${PROJECT_SCENE_QUERY}=${encodeURIComponent(selectionOverride.key)}`
244
+ );
245
+ }