@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +19 -0
- package/package.json +90 -0
- package/src/account.ts +210 -0
- package/src/chrome.ts +83 -0
- package/src/client.ts +1547 -0
- package/src/commands.ts +66 -0
- package/src/contributions.ts +985 -0
- package/src/document-probe.ts +237 -0
- package/src/editor-view.ts +220 -0
- package/src/extension.ts +40 -0
- package/src/generations.ts +178 -0
- package/src/host.ts +1167 -0
- package/src/http-transport.browser.ts +14 -0
- package/src/http-transport.node.ts +19 -0
- package/src/index.ts +128 -0
- package/src/layout-arrangements.ts +5 -0
- package/src/layouts.tsx +108 -0
- package/src/looks.ts +14 -0
- package/src/project/output-roots.ts +73 -0
- package/src/project/tab-census.ts +149 -0
- package/src/project-tool-catalog.ts +96 -0
- package/src/selection.tsx +108 -0
- package/src/services.ts +18 -0
- package/src/session/build-report.ts +19 -0
- package/src/session/collaboration-types.ts +262 -0
- package/src/session/command-table.ts +333 -0
- package/src/session/discovery.ts +90 -0
- package/src/session/editor-brand.ts +73 -0
- package/src/session/editor-compatibility.ts +248 -0
- package/src/session/editor-control-lifecycle.ts +68 -0
- package/src/session/editor-control-protocol.ts +5 -0
- package/src/session/entrypoint-selection-readers.ts +66 -0
- package/src/session/entrypoint-selection-source.ts +120 -0
- package/src/session/game-css-scope.ts +30 -0
- package/src/session/product-create.ts +24 -0
- package/src/session/product-locator.ts +389 -0
- package/src/session/project-module-url.ts +245 -0
- package/src/session/registry-format.ts +203 -0
- package/src/session/relative-path-guard.ts +56 -0
- package/src/session/source-glob.ts +15 -0
- package/src/session/tool-contribution-convention.ts +116 -0
- package/src/session/workbench-locator.ts +650 -0
- package/src/session.ts +41 -0
- package/src/share.ts +160 -0
- package/src/tools/errors.ts +91 -0
- package/src/tools/provider-execution.ts +70 -0
- package/src/tools/registry.ts +341 -0
- package/src/tools/types.ts +159 -0
- package/src/transport.ts +97 -0
- package/src/types.ts +1581 -0
- package/src/views.ts +164 -0
- package/src/widgets/design-system.ts +93 -0
- package/src/widgets/editor-appearance.ts +149 -0
- package/src/widgets/editor-material.ts +83 -0
- package/src/widgets/icon-set-registry.ts +105 -0
- package/src/widgets/index.ts +71 -0
- package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
- package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
- package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
- package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
- package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
- package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
- package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
- package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
- package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
- package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
- package/src/widgets/inspector-widgets/color-utils.ts +201 -0
- package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
- package/src/widgets/inspector-widgets/index.ts +24 -0
- package/src/widgets/inspector-widgets/shared.tsx +140 -0
- package/src/widgets/interactive-edit-scope.ts +33 -0
- package/src/widgets/patterns/Dialog.tsx +129 -0
- package/src/widgets/patterns/Fields.tsx +44 -0
- package/src/widgets/patterns/List.tsx +25 -0
- package/src/widgets/patterns/StateSurface.tsx +40 -0
- package/src/widgets/patterns/Surfaces.tsx +122 -0
- package/src/widgets/patterns/Tabs.tsx +80 -0
- package/src/widgets/patterns/Toolbar.tsx +72 -0
- package/src/widgets/patterns/Tree.tsx +72 -0
- package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
- package/src/widgets/primitives/Button.tsx +62 -0
- package/src/widgets/primitives/ColorInput.tsx +78 -0
- package/src/widgets/primitives/DraftTextInput.tsx +63 -0
- package/src/widgets/primitives/EditorIcon.tsx +157 -0
- package/src/widgets/primitives/FormControls.tsx +88 -0
- package/src/widgets/primitives/HoverPreview.tsx +96 -0
- package/src/widgets/primitives/JsonInput.tsx +113 -0
- package/src/widgets/primitives/Layout.tsx +100 -0
- package/src/widgets/primitives/Menu.tsx +140 -0
- package/src/widgets/primitives/NumberInput.tsx +169 -0
- package/src/widgets/primitives/Panel.tsx +80 -0
- package/src/widgets/primitives/SectionHeader.tsx +77 -0
- package/src/widgets/primitives/Text.tsx +54 -0
- package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
- package/src/widgets/primitives/Tooltip.tsx +204 -0
- package/src/widgets/primitives/Vec3Input.tsx +70 -0
- package/src/widgets/primitives/banner-tones.ts +32 -0
- package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
- package/src/widgets/primitives/editor-icons.ts +245 -0
- package/src/widgets/primitives/panel-header-styles.ts +42 -0
- package/src/widgets/theme.ts +2633 -0
- package/src/widgets/z-index.ts +25 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH CODE-OSS WORKBENCH A PROJECT OPENS IN — one declaration, one resolver.
|
|
3
|
+
*
|
|
4
|
+
* `vgai edit` is the workbench. The workbench itself is not in this repository
|
|
5
|
+
* and is not published to a registry yet, so the one thing a project must state
|
|
6
|
+
* is WHERE the bytes are:
|
|
7
|
+
*
|
|
8
|
+
* ```json
|
|
9
|
+
* { "workbench": "/Users/me/volter/code-oss-releases/393f0b98ade1" }
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* in `<project>/.vgai/workbench.json`. `--workbench <dir>` overrides it for one
|
|
13
|
+
* invocation. There is deliberately NO env var, NO setting and NO search path:
|
|
14
|
+
* a machine-local path that decides which editor a person gets belongs in the
|
|
15
|
+
* project's own machine-local file, where `vgai status` can report it and a
|
|
16
|
+
* second person can read it, not in whichever shell happened to start the
|
|
17
|
+
* session.
|
|
18
|
+
*
|
|
19
|
+
* AND A PRODUCT THAT IS INSTALLED CARRIES ITS OWN (B2, 2026-09-21). A person
|
|
20
|
+
* who has just run `npx @vgai/model-editor create my-models` has no release on
|
|
21
|
+
* their machine and no reason to have one, so the third step of the resolution
|
|
22
|
+
* is the PRODUCT'S declaration — `package.json#vgai.product.workbench`, the
|
|
23
|
+
* published release those bytes are (ARCHITECTURE-CORE §The target shape: one
|
|
24
|
+
* product number per release, pinning the fork, the kit, the API and its
|
|
25
|
+
* media). It is fetched once into `~/.vgai/workbenches/<tag>/` and then
|
|
26
|
+
* DECLARED in this project's own file like any other, so the sentence above
|
|
27
|
+
* still holds where it matters: the record of which workbench this machine
|
|
28
|
+
* opens this project in is the project's file, always, and `~/.vgai/workbenches`
|
|
29
|
+
* is a byte cache keyed by a release tag that decides nothing.
|
|
30
|
+
*
|
|
31
|
+
* ONE DIRECTORY, TWO KINDS, and the marker file decides:
|
|
32
|
+
* - a RELEASE — `BUILD.json` beside an extracted `vscode-reh-web-*` tree, the
|
|
33
|
+
* pair `scripts/workbench/build-release.mjs` cuts. Its `commit` is what this
|
|
34
|
+
* reports.
|
|
35
|
+
* - a fork CHECKOUT — `scripts/code-server.sh`. `git rev-parse HEAD` is the
|
|
36
|
+
* commit, because a checkout's commit is a fact about the working tree and
|
|
37
|
+
* not about any file in it.
|
|
38
|
+
*
|
|
39
|
+
* AND A WORKBENCH IS ONE PRODUCT'S (P3, 2026-09-21). Our tier is not in the
|
|
40
|
+
* fork any more: it is OVERLAID on a checkout at a pin, the kit's half and ONE
|
|
41
|
+
* product's half together (ARCHITECTURE-CORE §The target shape, rule 6). So a
|
|
42
|
+
* workbench carries a product the way it carries a commit — a release states it
|
|
43
|
+
* in `BUILD.json`, a checkout in the `.vgai-overlay.json` the overlay writes —
|
|
44
|
+
* and opening a project in a workbench built for the OTHER product is refused
|
|
45
|
+
* by name. It has to be: the mismatch is invisible otherwise. The page mounts,
|
|
46
|
+
* the session serves this project's product entry into it, and what the person
|
|
47
|
+
* gets is the other product's title, its look and its commands around it.
|
|
48
|
+
*
|
|
49
|
+
* WHO CALLS THIS, and why twice. The CLI resolves to REFUSE EARLY — a bad
|
|
50
|
+
* declaration must be named before a session is started, not after. The session
|
|
51
|
+
* resolves to SPAWN and to REPORT (`EditorState.workbench`), because the REH is
|
|
52
|
+
* the session's child and a process reports its own children. One function, one
|
|
53
|
+
* set of refusal texts; calling it twice costs two file reads.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { execFileSync } from 'node:child_process';
|
|
57
|
+
import { createHash } from 'node:crypto';
|
|
58
|
+
import {
|
|
59
|
+
createWriteStream,
|
|
60
|
+
existsSync,
|
|
61
|
+
mkdirSync,
|
|
62
|
+
readFileSync,
|
|
63
|
+
renameSync,
|
|
64
|
+
rmSync,
|
|
65
|
+
writeFileSync,
|
|
66
|
+
} from 'node:fs';
|
|
67
|
+
import { homedir } from 'node:os';
|
|
68
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
69
|
+
import { Readable } from 'node:stream';
|
|
70
|
+
import { pipeline } from 'node:stream/promises';
|
|
71
|
+
import {
|
|
72
|
+
PRODUCT_DECLARATION_KEY,
|
|
73
|
+
type ProductIdentity,
|
|
74
|
+
workbenchProductId,
|
|
75
|
+
} from './product-locator';
|
|
76
|
+
|
|
77
|
+
/** Where a project declares its workbench, relative to the project root. */
|
|
78
|
+
export const WORKBENCH_LOCATOR_PATH = '.vgai/workbench.json';
|
|
79
|
+
|
|
80
|
+
/** The release's own record, written beside the tarball by
|
|
81
|
+
* `scripts/workbench/build-release.mjs`. */
|
|
82
|
+
const RELEASE_RECORD = 'BUILD.json';
|
|
83
|
+
/** The REH launcher inside a fork checkout — the marker that says "sources". */
|
|
84
|
+
const SOURCES_LAUNCHER = join('scripts', 'code-server.sh');
|
|
85
|
+
/** What `scripts/workbench/overlay.mjs` leaves in a checkout it has overlaid. */
|
|
86
|
+
const OVERLAY_RECORD = '.vgai-overlay.json';
|
|
87
|
+
|
|
88
|
+
/** What the session reports about the workbench it is running. */
|
|
89
|
+
export interface WorkbenchIdentity {
|
|
90
|
+
readonly kind: 'release' | 'sources';
|
|
91
|
+
/** The directory named by the declaration or the flag, resolved absolute. */
|
|
92
|
+
readonly dir: string;
|
|
93
|
+
/** The Code-OSS fork commit these bytes are. */
|
|
94
|
+
readonly commit: string;
|
|
95
|
+
/** The product whose workbench half is overlaid on them — `model-editor`,
|
|
96
|
+
* `game-editor`. Reported beside the commit by `vgai status`. */
|
|
97
|
+
readonly product: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Everything the session needs to run it, identity included. */
|
|
101
|
+
export interface ResolvedWorkbench extends WorkbenchIdentity {
|
|
102
|
+
/** The executable to spawn, already known to exist. */
|
|
103
|
+
readonly serverBin: string;
|
|
104
|
+
/** Its working directory — the package root, or the checkout. */
|
|
105
|
+
readonly cwd: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The exact file to write, quoted in every refusal that wants one. */
|
|
109
|
+
export function workbenchDeclarationExample(projectRoot: string): string {
|
|
110
|
+
return (
|
|
111
|
+
`${join(projectRoot, WORKBENCH_LOCATOR_PATH)}:\n` +
|
|
112
|
+
' { "workbench": "<path to an extracted Code-OSS release, or to a fork checkout>" }'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The workbench directory this project declares, or `null` when it declares
|
|
118
|
+
* none. Throws when the file exists and is not exactly `{ "workbench": <path> }`
|
|
119
|
+
* — an unrecognized key is an error, never a silent pass-through, for the same
|
|
120
|
+
* reason every on-disk document this repo reads is strict.
|
|
121
|
+
*/
|
|
122
|
+
export function readWorkbenchDeclaration(projectRoot: string): string | null {
|
|
123
|
+
const file = join(projectRoot, WORKBENCH_LOCATOR_PATH);
|
|
124
|
+
if (!existsSync(file)) return null;
|
|
125
|
+
let parsed: unknown;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${file} is not valid JSON (${error instanceof Error ? error.message : String(error)}). It says ` +
|
|
131
|
+
`which Code-OSS workbench this project opens in, and nothing else:\n${workbenchDeclarationExample(projectRoot)}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${file} must be a JSON object naming one workbench:\n${workbenchDeclarationExample(projectRoot)}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const record = parsed as Record<string, unknown>;
|
|
140
|
+
const extra = Object.keys(record).filter((key) => key !== 'workbench');
|
|
141
|
+
if (extra.length > 0) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`${file} carries ${extra.map((key) => `"${key}"`).join(', ')}, which this document has no meaning for. ` +
|
|
144
|
+
`It names ONE key:\n${workbenchDeclarationExample(projectRoot)}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const declared = record['workbench'];
|
|
148
|
+
if (typeof declared !== 'string' || declared.trim() === '') {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`${file} names no workbench directory:\n${workbenchDeclarationExample(projectRoot)}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return isAbsolute(declared) ? resolve(declared) : resolve(projectRoot, declared);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* WRITE THE DECLARATION — the one record of which workbench THIS machine opens
|
|
158
|
+
* this project in. Written by `<product> create --workbench <dir>`, and written
|
|
159
|
+
* by the fetch below, so the two paths cannot leave two different shapes of
|
|
160
|
+
* file behind.
|
|
161
|
+
*/
|
|
162
|
+
export function writeWorkbenchDeclaration(projectRoot: string, workbenchDir: string): void {
|
|
163
|
+
mkdirSync(join(projectRoot, '.vgai'), { recursive: true });
|
|
164
|
+
writeFileSync(
|
|
165
|
+
join(projectRoot, WORKBENCH_LOCATOR_PATH),
|
|
166
|
+
`${JSON.stringify({ workbench: resolve(workbenchDir) }, null, 2)}\n`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The url the workbench answers on — the ONE url a person opens under the
|
|
172
|
+
* frame. Spelled here so the CLI's launch line and the session's tab bijection
|
|
173
|
+
* cannot disagree about it.
|
|
174
|
+
*
|
|
175
|
+
* `?project=<id>` is the one boot param the vgai editor carries; the session's
|
|
176
|
+
* one-origin proxy redirects it to the workbench's own `?folder=`, and
|
|
177
|
+
* `frame-proxy.ts` says why it must be a redirect.
|
|
178
|
+
*/
|
|
179
|
+
export function workbenchUrl(proxyPort: number, projectRoot: string): string {
|
|
180
|
+
const id = projectRoot.split('/').filter(Boolean).pop() ?? '';
|
|
181
|
+
return `http://127.0.0.1:${proxyPort}/?project=${encodeURIComponent(id)}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** This machine in the build script's own platform vocabulary. */
|
|
185
|
+
function machinePlatform(): string {
|
|
186
|
+
return `${process.platform}-${process.arch}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Read one workbench directory and check this machine can run it, or throw with
|
|
191
|
+
* the exact fix. Both kinds are refused by NAME rather than fallen back from: a
|
|
192
|
+
* boot that silently was not the workbench you named measures nothing.
|
|
193
|
+
*
|
|
194
|
+
* `expectedProduct` is the product the PROJECT resolves to (`@vgai/game-editor`
|
|
195
|
+
* → `game-editor`). Passing it is how a caller asks "is this workbench this
|
|
196
|
+
* project's?"; omitting it reads the workbench without judging it, which is what
|
|
197
|
+
* `<product> create --workbench <dir>` does, having no installed project yet.
|
|
198
|
+
*/
|
|
199
|
+
export function resolveWorkbench(dir: string, expectedProduct?: string): ResolvedWorkbench {
|
|
200
|
+
const root = resolve(dir);
|
|
201
|
+
const resolved = existsSync(join(root, RELEASE_RECORD))
|
|
202
|
+
? resolveRelease(root)
|
|
203
|
+
: existsSync(join(root, SOURCES_LAUNCHER))
|
|
204
|
+
? resolveSources(root)
|
|
205
|
+
: null;
|
|
206
|
+
if (resolved === null) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`${root} is neither a Code-OSS release nor a fork checkout: it carries no ${RELEASE_RECORD} (the record ` +
|
|
209
|
+
`scripts/workbench/build-release.mjs writes beside its tarball) and no ${SOURCES_LAUNCHER}. Name the ` +
|
|
210
|
+
'directory the release was extracted into, or the fork checkout itself.',
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (expectedProduct !== undefined && resolved.product !== expectedProduct) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`${root} is a ${resolved.product} workbench and this project opens in ${expectedProduct}. A workbench is ` +
|
|
216
|
+
"built for ONE product — the editor kit and that product's workbench half are overlaid on the fork " +
|
|
217
|
+
"together — so its title, its look and its commands are the other product's, whatever this session " +
|
|
218
|
+
`serves into it. ${
|
|
219
|
+
resolved.kind === 'sources'
|
|
220
|
+
? `Re-overlay the checkout:\n node scripts/workbench/dev.mjs --checkout ${root} --product ${expectedProduct}`
|
|
221
|
+
: `Name a ${expectedProduct} release in .vgai/workbench.json, or cut one:\n node scripts/workbench/build-release.mjs --product ${expectedProduct} --platform ${machinePlatform()} --checkout <fork dir>`
|
|
222
|
+
}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return resolved;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The five fields the launch depends on — `target`, `platform`, `commit`,
|
|
230
|
+
* `serverBin` and `product`. The rest of `BUILD.json` is the build's own
|
|
231
|
+
* provenance (sizes, sha256, the node it was built with) and is read by a
|
|
232
|
+
* person, not by this.
|
|
233
|
+
*/
|
|
234
|
+
function resolveRelease(dir: string): ResolvedWorkbench {
|
|
235
|
+
const record = join(dir, RELEASE_RECORD);
|
|
236
|
+
const cut = `node scripts/workbench/build-release.mjs --product <id> --platform ${machinePlatform()} --checkout <fork dir> --out ${dir}`;
|
|
237
|
+
let parsed: Record<string, unknown>;
|
|
238
|
+
try {
|
|
239
|
+
parsed = JSON.parse(readFileSync(record, 'utf8')) as Record<string, unknown>;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
throw new Error(`${record} could not be read: ${String(error)}`);
|
|
242
|
+
}
|
|
243
|
+
const needed = ['target', 'platform', 'commit', 'serverBin', 'product'] as const;
|
|
244
|
+
const missing = needed.filter((key) => typeof parsed[key] !== 'string');
|
|
245
|
+
if (missing.length > 0) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`${record} is not a release record this understands: ${missing.join(', ')} ` +
|
|
248
|
+
`${missing.length === 1 ? 'is' : 'are'} missing or not a string. A release names ${needed.join(', ')} — ` +
|
|
249
|
+
'the five fields scripts/workbench/build-release.mjs writes and this one spawns from. A release cut ' +
|
|
250
|
+
'before the tier left the fork names no product, and there is no way to infer one from the bytes: the ' +
|
|
251
|
+
'kit and one product were compiled into them together. Re-cut it:\n ' +
|
|
252
|
+
cut,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const target = parsed['target'] as string;
|
|
256
|
+
const platform = parsed['platform'] as string;
|
|
257
|
+
const commit = parsed['commit'] as string;
|
|
258
|
+
const serverBin = parsed['serverBin'] as string;
|
|
259
|
+
const product = parsed['product'] as string;
|
|
260
|
+
|
|
261
|
+
const machine = machinePlatform();
|
|
262
|
+
if (platform !== machine) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`${dir} was built for ${platform} and this machine is ${machine}. A Code-OSS server package carries its ` +
|
|
265
|
+
"own platform's node binary and native modules, so it cannot run here — and this never falls back to a " +
|
|
266
|
+
`source checkout, because a boot that silently was not the release measures nothing. Cut the ${machine} ` +
|
|
267
|
+
`release:\n node scripts/workbench/build-release.mjs --product ${product} --platform ${machine} --checkout <fork dir>`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const packageRoot = join(dir, target);
|
|
272
|
+
const serverBinPath = join(packageRoot, serverBin);
|
|
273
|
+
if (!existsSync(serverBinPath)) {
|
|
274
|
+
const tarball =
|
|
275
|
+
typeof parsed['tarball'] === 'string'
|
|
276
|
+
? (parsed['tarball'] as string)
|
|
277
|
+
: `${target}-<commit>.tar.gz`;
|
|
278
|
+
throw new Error(
|
|
279
|
+
`${dir} records ${target}, but ${serverBinPath} is not there — the tarball has not been extracted beside ` +
|
|
280
|
+
`${RELEASE_RECORD}. Run:\n tar -xzf ${join(dir, tarball)} -C ${dir}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return { kind: 'release', dir, commit, product, serverBin: serverBinPath, cwd: packageRoot };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A fork checkout. The commit is the working tree's, read with git: a checkout
|
|
288
|
+
* has no file that states it, and a stale one would name bytes nobody is
|
|
289
|
+
* running. The PRODUCT is the overlay's own record — a checkout with none has
|
|
290
|
+
* no vgai tier in it at all, and that is refused here rather than left to
|
|
291
|
+
* surface as a workbench where `VGAI: Open Workspace` does not exist.
|
|
292
|
+
*/
|
|
293
|
+
function resolveSources(dir: string): ResolvedWorkbench {
|
|
294
|
+
let commit: string;
|
|
295
|
+
try {
|
|
296
|
+
commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();
|
|
297
|
+
} catch (error) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`${dir} carries ${SOURCES_LAUNCHER}, so it reads as a Code-OSS fork checkout, but \`git rev-parse HEAD\` ` +
|
|
300
|
+
`there failed (${error instanceof Error ? error.message : String(error)}). A checkout's commit is the ` +
|
|
301
|
+
'only record of which fork this workbench is.',
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const marker = join(dir, OVERLAY_RECORD);
|
|
305
|
+
if (!existsSync(marker)) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`${dir} is a Code-OSS checkout with no vgai overlay: ${OVERLAY_RECORD} is not there, so nothing of the ` +
|
|
308
|
+
'editor is compiled into it and the workbench would come up as plain Code-OSS. Overlay and compile it:\n' +
|
|
309
|
+
` node scripts/workbench/dev.mjs --checkout ${dir} --product <model-editor|game-editor>`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
let product: unknown;
|
|
313
|
+
try {
|
|
314
|
+
product = (JSON.parse(readFileSync(marker, 'utf8')) as Record<string, unknown>)['product'];
|
|
315
|
+
} catch (error) {
|
|
316
|
+
throw new Error(`${marker} could not be read: ${String(error)}`);
|
|
317
|
+
}
|
|
318
|
+
if (typeof product !== 'string' || product === '') {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`${marker} names no product. It is written by scripts/workbench/overlay.mjs and says which product's ` +
|
|
321
|
+
'workbench half is in this checkout; re-run the overlay rather than editing it.',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
kind: 'sources',
|
|
326
|
+
dir,
|
|
327
|
+
commit,
|
|
328
|
+
product,
|
|
329
|
+
serverBin: join(dir, SOURCES_LAUNCHER),
|
|
330
|
+
cwd: dir,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// THE PRODUCT'S OWN WORKBENCH — the third resolution step, and the only one
|
|
336
|
+
// that needs nothing on the machine but the product.
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
/** The fork's own repository, where a cut release is published. Private today;
|
|
340
|
+
* the fetch below is the same call either way, with a token or without one. */
|
|
341
|
+
const RELEASE_REPO = 'volter-ai/code-oss';
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* WHERE FETCHED BYTES LIVE — one directory per release tag, per machine.
|
|
345
|
+
*
|
|
346
|
+
* Keyed by the TAG and nothing else, because that is what makes it a cache and
|
|
347
|
+
* not a decision: two projects on the same release share one 723 MB directory,
|
|
348
|
+
* a second `create` consults no network, and a release is immutable, so a tag
|
|
349
|
+
* that is here is the tag that was published. Which workbench a project opens
|
|
350
|
+
* in is still recorded in the PROJECT (`.vgai/workbench.json`, written the
|
|
351
|
+
* moment this resolves) — nothing ever searches this directory.
|
|
352
|
+
*/
|
|
353
|
+
export const WORKBENCH_CACHE_ROOT = join(homedir(), '.vgai', 'workbenches');
|
|
354
|
+
|
|
355
|
+
/** Which of the three steps answered. */
|
|
356
|
+
export type WorkbenchSource = 'flag' | 'declaration' | 'release';
|
|
357
|
+
|
|
358
|
+
/** The resolution, and how it got there — the launch line says both. */
|
|
359
|
+
export interface ProjectWorkbench extends ResolvedWorkbench {
|
|
360
|
+
readonly source: WorkbenchSource;
|
|
361
|
+
/** The release tag these bytes are, when the product's declaration found them. */
|
|
362
|
+
readonly tag: string | null;
|
|
363
|
+
/** True when THIS call downloaded them — the launch line says `fetched from
|
|
364
|
+
* <tag>` exactly once per machine per release, and never again. */
|
|
365
|
+
readonly fetched: boolean;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Where the fetch talks. `log` is a line; `progress` is ONE line rewritten in
|
|
369
|
+
* place (216 MB is a wait, and a wait with no number is a hang). */
|
|
370
|
+
export interface WorkbenchFetchIO {
|
|
371
|
+
readonly log: (line: string) => void;
|
|
372
|
+
readonly progress?: (line: string) => void;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* THE WORKBENCH THIS PROJECT OPENS IN, resolved in three steps and saying which
|
|
377
|
+
* one answered:
|
|
378
|
+
*
|
|
379
|
+
* 1. `--workbench <dir>` — one launch, writes nothing.
|
|
380
|
+
* 2. `<project>/.vgai/workbench.json` — this machine's record for this project.
|
|
381
|
+
* 3. the PRODUCT's declared release — fetched once, then written into (2).
|
|
382
|
+
*
|
|
383
|
+
* Step 3 is what makes `npx @vgai/model-editor create my-models` open something
|
|
384
|
+
* on a machine that has never built anything, and it is why the CLI resolves
|
|
385
|
+
* the product BEFORE the workbench.
|
|
386
|
+
*/
|
|
387
|
+
export async function resolveWorkbenchForProject(options: {
|
|
388
|
+
projectRoot: string;
|
|
389
|
+
product: ProductIdentity;
|
|
390
|
+
/** `--workbench <dir>`, when the person passed one. */
|
|
391
|
+
override?: string | undefined;
|
|
392
|
+
io: WorkbenchFetchIO;
|
|
393
|
+
}): Promise<ProjectWorkbench> {
|
|
394
|
+
const { projectRoot, product, override, io } = options;
|
|
395
|
+
const productId = workbenchProductId(product.name);
|
|
396
|
+
if (override !== undefined)
|
|
397
|
+
return {
|
|
398
|
+
...resolveWorkbench(resolve(override), productId),
|
|
399
|
+
source: 'flag',
|
|
400
|
+
tag: null,
|
|
401
|
+
fetched: false,
|
|
402
|
+
};
|
|
403
|
+
const declared = readWorkbenchDeclaration(projectRoot);
|
|
404
|
+
if (declared !== null)
|
|
405
|
+
return {
|
|
406
|
+
...resolveWorkbench(declared, productId),
|
|
407
|
+
source: 'declaration',
|
|
408
|
+
tag: null,
|
|
409
|
+
fetched: false,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
if (product.workbench === null) throw new Error(
|
|
413
|
+
`${product.name} is a private source product with no released workbench. ` +
|
|
414
|
+
`Pass --workbench <compiled checkout> or declare it in ${WORKBENCH_LOCATOR_PATH}. ` +
|
|
415
|
+
'Public distribution requires a pinned workbench release and checksum.',
|
|
416
|
+
);
|
|
417
|
+
const tag = product.workbench.release;
|
|
418
|
+
const dir = join(WORKBENCH_CACHE_ROOT, tag);
|
|
419
|
+
// A CACHED TAG IS NEVER RE-FETCHED. The marker is the release's own record,
|
|
420
|
+
// so a directory that lost its extraction reads as absent and is fetched
|
|
421
|
+
// again rather than resolving into a refusal a person cannot act on.
|
|
422
|
+
const fetched = !existsSync(join(dir, RELEASE_RECORD));
|
|
423
|
+
if (fetched) await fetchDeclaredRelease(product, tag, dir, io);
|
|
424
|
+
else io.log(`Workbench ${tag} is already installed (${dir}).`);
|
|
425
|
+
const workbench = resolveWorkbench(dir, productId);
|
|
426
|
+
writeWorkbenchDeclaration(projectRoot, dir);
|
|
427
|
+
io.log(`Declared in ${WORKBENCH_LOCATOR_PATH}: ${dir}`);
|
|
428
|
+
return { ...workbench, source: 'release', tag, fetched };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** A GitHub release asset, as much of one as this reads. */
|
|
432
|
+
interface ReleaseAsset {
|
|
433
|
+
readonly id: number;
|
|
434
|
+
readonly name: string;
|
|
435
|
+
readonly size: number;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The token, from the two doors a person has: the environment, or the `gh` they
|
|
440
|
+
* are already logged into. Deliberately not a file of ours, not a setting and
|
|
441
|
+
* not a prompt — a credential this reads is one somebody else's tool already
|
|
442
|
+
* owns.
|
|
443
|
+
*/
|
|
444
|
+
function githubToken(): string | null {
|
|
445
|
+
const fromEnv = process.env['GITHUB_TOKEN'];
|
|
446
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv.trim();
|
|
447
|
+
try {
|
|
448
|
+
const out = execFileSync('gh', ['auth', 'token'], {
|
|
449
|
+
encoding: 'utf8',
|
|
450
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
451
|
+
}).trim();
|
|
452
|
+
return out === '' ? null : out;
|
|
453
|
+
} catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function apiHeaders(token: string | null): Record<string, string> {
|
|
459
|
+
return {
|
|
460
|
+
Accept: 'application/vnd.github+json',
|
|
461
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
462
|
+
'User-Agent': 'vgai-workbench-locator',
|
|
463
|
+
...(token === null ? {} : { Authorization: `Bearer ${token}` }),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function megabytes(bytes: number): string {
|
|
468
|
+
return (bytes / 1024 ** 2).toFixed(0);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The line a refusal prints when the release is not reachable — both doors,
|
|
472
|
+
* named, because "no credentials" with no names is the same as no message. */
|
|
473
|
+
function tokenDoors(): string {
|
|
474
|
+
return (
|
|
475
|
+
' GITHUB_TOKEN=<a token with repo scope> — in this shell, or\n' +
|
|
476
|
+
' gh auth login — so `gh auth token` answers'
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* FETCH ONE PUBLISHED RELEASE into `dir`, atomically: everything lands in a
|
|
482
|
+
* sibling `.partial` directory and is renamed into place at the end, so a
|
|
483
|
+
* directory under `~/.vgai/workbenches` either is a whole workbench or is not
|
|
484
|
+
* there. Any failure — a refused download, a sha that does not match the
|
|
485
|
+
* product's pin, a tar that dies — deletes the partial and refuses.
|
|
486
|
+
*/
|
|
487
|
+
async function fetchDeclaredRelease(
|
|
488
|
+
product: ProductIdentity,
|
|
489
|
+
tag: string,
|
|
490
|
+
dir: string,
|
|
491
|
+
io: WorkbenchFetchIO,
|
|
492
|
+
): Promise<void> {
|
|
493
|
+
if (product.workbench === null) throw new Error('Cannot download an unpinned workbench.');
|
|
494
|
+
const productId = workbenchProductId(product.name);
|
|
495
|
+
const machine = machinePlatform();
|
|
496
|
+
const token = githubToken();
|
|
497
|
+
const partial = join(WORKBENCH_CACHE_ROOT, `.${tag}.${process.pid}.partial`);
|
|
498
|
+
const cutLine =
|
|
499
|
+
` node scripts/workbench/build-release.mjs --product ${productId} --platform ${machine} ` +
|
|
500
|
+
'--checkout <fork dir> --publish';
|
|
501
|
+
|
|
502
|
+
const response = await fetch(
|
|
503
|
+
`https://api.github.com/repos/${RELEASE_REPO}/releases/tags/${encodeURIComponent(tag)}`,
|
|
504
|
+
{ headers: apiHeaders(token) },
|
|
505
|
+
);
|
|
506
|
+
if (response.status === 404 && token === null)
|
|
507
|
+
throw new Error(
|
|
508
|
+
`${product.name} opens in the Code-OSS workbench published as ${tag} on ${RELEASE_REPO}, and ` +
|
|
509
|
+
'that repository answers 404 to this machine — it is private, and nothing here has a ' +
|
|
510
|
+
'GitHub token to read it with. Two doors, either one:\n' +
|
|
511
|
+
`${tokenDoors()}\n` +
|
|
512
|
+
'Or name a workbench you already have, which needs no network at all:\n' +
|
|
513
|
+
' volter-editor edit --workbench <dir>',
|
|
514
|
+
);
|
|
515
|
+
if (response.status === 404)
|
|
516
|
+
throw new Error(
|
|
517
|
+
`${RELEASE_REPO} has no release tagged ${tag}, and that is the workbench ${product.name} ` +
|
|
518
|
+
`declares (package.json#${PRODUCT_DECLARATION_KEY}.workbench). Either the declaration names ` +
|
|
519
|
+
'a tag that was never published, or this token cannot see the repository. Cut and publish ' +
|
|
520
|
+
`it:\n${cutLine}`,
|
|
521
|
+
);
|
|
522
|
+
if (!response.ok)
|
|
523
|
+
throw new Error(
|
|
524
|
+
`${RELEASE_REPO}'s release ${tag} answered ${response.status} ${response.statusText}. That is ` +
|
|
525
|
+
`the workbench ${product.name} declares; ` +
|
|
526
|
+
`${token === null ? 'no token was found' : 'the token this machine has was used'}.` +
|
|
527
|
+
(token === null ? `\n${tokenDoors()}` : ''),
|
|
528
|
+
);
|
|
529
|
+
const assets = ((await response.json()) as { assets?: ReleaseAsset[] }).assets ?? [];
|
|
530
|
+
const record = assets.find((asset) => asset.name === RELEASE_RECORD);
|
|
531
|
+
const tarball = assets.find((asset) => asset.name.endsWith('.tar.gz'));
|
|
532
|
+
if (record === undefined || tarball === undefined)
|
|
533
|
+
throw new Error(
|
|
534
|
+
`Release ${tag} on ${RELEASE_REPO} carries ` +
|
|
535
|
+
`${assets.length === 0 ? 'no assets' : assets.map((asset) => asset.name).join(', ')}. ` +
|
|
536
|
+
`A workbench release is a tarball and its ${RELEASE_RECORD}, both uploaded by ` +
|
|
537
|
+
`\`build-release.mjs --publish\`; re-publish it:\n${cutLine}`,
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
rmSync(partial, { recursive: true, force: true });
|
|
541
|
+
mkdirSync(partial, { recursive: true });
|
|
542
|
+
try {
|
|
543
|
+
// THE RECORD FIRST — it is under a kilobyte, and it says whether these bytes
|
|
544
|
+
// can run on this machine at all. Downloading 216 MB and refusing afterwards
|
|
545
|
+
// would be a refusal that cost a person ten minutes.
|
|
546
|
+
const recordText = await downloadAssetText(record, token);
|
|
547
|
+
const built = JSON.parse(recordText) as Record<string, unknown>;
|
|
548
|
+
if (built['platform'] !== machine)
|
|
549
|
+
throw new Error(
|
|
550
|
+
`Release ${tag} was built for ${String(built['platform'])} and this machine is ${machine}. A ` +
|
|
551
|
+
"Code-OSS server package carries its own platform's node binary and native modules, so it " +
|
|
552
|
+
`cannot run here, and nothing falls back. Cut and publish the ${machine} release:\n${cutLine}`,
|
|
553
|
+
);
|
|
554
|
+
if (built['tarballSha256'] !== product.workbench.tarballSha256)
|
|
555
|
+
throw new Error(
|
|
556
|
+
`Release ${tag}'s own ${RELEASE_RECORD} records tarballSha256 ${String(built['tarballSha256'])} ` +
|
|
557
|
+
`and ${product.name} pins ${product.workbench.tarballSha256}. A release is immutable, so these ` +
|
|
558
|
+
'two cannot disagree about one tag: either the product declares the wrong release, or the ' +
|
|
559
|
+
'release was replaced. Nothing was downloaded.',
|
|
560
|
+
);
|
|
561
|
+
writeFileSync(join(partial, RELEASE_RECORD), recordText);
|
|
562
|
+
|
|
563
|
+
io.log(
|
|
564
|
+
`Fetching the ${productId} workbench ${tag} from ${RELEASE_REPO} — ` +
|
|
565
|
+
`${megabytes(tarball.size)} MB, once per machine.`,
|
|
566
|
+
);
|
|
567
|
+
const tarballPath = join(partial, tarball.name);
|
|
568
|
+
let announced = 0;
|
|
569
|
+
const sha = await downloadAsset(tarball, token, tarballPath, (received) => {
|
|
570
|
+
if (received - announced < 4 * 1024 ** 2 && received !== tarball.size) return;
|
|
571
|
+
announced = received;
|
|
572
|
+
io.progress?.(
|
|
573
|
+
` ${megabytes(received)} / ${megabytes(tarball.size)} MB ` +
|
|
574
|
+
`(${Math.floor((received / tarball.size) * 100)}%)`,
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
if (sha !== product.workbench.tarballSha256)
|
|
578
|
+
throw new Error(
|
|
579
|
+
`The download of ${tag} hashes to ${sha} and ${product.name} pins ` +
|
|
580
|
+
`${product.workbench.tarballSha256}. These are not the bytes this product runs in; the ` +
|
|
581
|
+
'partial download was deleted and nothing was extracted.',
|
|
582
|
+
);
|
|
583
|
+
io.log(` sha256 ${sha.slice(0, 12)}… matches the pin. Extracting…`);
|
|
584
|
+
execFileSync('tar', ['-xzf', tarballPath, '-C', partial], {
|
|
585
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
586
|
+
});
|
|
587
|
+
// The tarball has done its job and is 216 MB; the extraction is what runs.
|
|
588
|
+
rmSync(tarballPath, { force: true });
|
|
589
|
+
if (existsSync(join(dir, RELEASE_RECORD))) {
|
|
590
|
+
// Another process won the same race. A release is immutable, so its
|
|
591
|
+
// directory is ours too — take it and drop this copy.
|
|
592
|
+
rmSync(partial, { recursive: true, force: true });
|
|
593
|
+
} else {
|
|
594
|
+
rmSync(dir, { recursive: true, force: true });
|
|
595
|
+
renameSync(partial, dir);
|
|
596
|
+
}
|
|
597
|
+
io.log(`Workbench installed at ${dir}.`);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
rmSync(partial, { recursive: true, force: true });
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** One asset's bytes as text — for `BUILD.json`, which is a kilobyte. */
|
|
605
|
+
async function downloadAssetText(asset: ReleaseAsset, token: string | null): Promise<string> {
|
|
606
|
+
const response = await fetch(
|
|
607
|
+
`https://api.github.com/repos/${RELEASE_REPO}/releases/assets/${asset.id}`,
|
|
608
|
+
{ headers: { ...apiHeaders(token), Accept: 'application/octet-stream' } },
|
|
609
|
+
);
|
|
610
|
+
if (!response.ok)
|
|
611
|
+
throw new Error(
|
|
612
|
+
`${asset.name} of ${RELEASE_REPO} answered ${response.status} ${response.statusText}.`,
|
|
613
|
+
);
|
|
614
|
+
return await response.text();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* One asset's bytes to a file, hashing them ON THE WAY THROUGH — a 216 MB
|
|
619
|
+
* tarball is not read twice, and it is never held in memory.
|
|
620
|
+
*/
|
|
621
|
+
async function downloadAsset(
|
|
622
|
+
asset: ReleaseAsset,
|
|
623
|
+
token: string | null,
|
|
624
|
+
destination: string,
|
|
625
|
+
onProgress: (received: number) => void,
|
|
626
|
+
): Promise<string> {
|
|
627
|
+
const response = await fetch(
|
|
628
|
+
`https://api.github.com/repos/${RELEASE_REPO}/releases/assets/${asset.id}`,
|
|
629
|
+
{ headers: { ...apiHeaders(token), Accept: 'application/octet-stream' } },
|
|
630
|
+
);
|
|
631
|
+
if (!response.ok || response.body === null)
|
|
632
|
+
throw new Error(
|
|
633
|
+
`${asset.name} of ${RELEASE_REPO} answered ${response.status} ${response.statusText}.`,
|
|
634
|
+
);
|
|
635
|
+
const hash = createHash('sha256');
|
|
636
|
+
let received = 0;
|
|
637
|
+
await pipeline(
|
|
638
|
+
Readable.fromWeb(response.body as never),
|
|
639
|
+
async function* (source: AsyncIterable<Buffer>) {
|
|
640
|
+
for await (const chunk of source) {
|
|
641
|
+
hash.update(chunk);
|
|
642
|
+
received += chunk.length;
|
|
643
|
+
onProgress(received);
|
|
644
|
+
yield chunk;
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
createWriteStream(destination),
|
|
648
|
+
);
|
|
649
|
+
return hash.digest('hex');
|
|
650
|
+
}
|