@world-engines/project-setup 0.1.0-alpha.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.
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from "node:fs";
2
+ function loadPackageCatalog() {
3
+ const parsed = JSON.parse(readFileSync(new URL("./worldengine-package-catalog.json", import.meta.url), "utf8"));
4
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
5
+ throw new Error("worldengine_package_catalog_invalid");
6
+ const catalog = parsed;
7
+ if (catalog.schema_version !== 1 || !Array.isArray(catalog.packages) || catalog.packages.length === 0)
8
+ throw new Error("worldengine_package_catalog_invalid");
9
+ const names = new Set();
10
+ const directories = new Set();
11
+ for (const entry of catalog.packages) {
12
+ if (typeof entry?.name !== "string" || !/^@(?:chat|world-engines|worldengine)\/[a-z0-9-]+$/.test(entry.name))
13
+ throw new Error("worldengine_package_catalog_name_invalid");
14
+ if (typeof entry.directory !== "string" || !/^(?:packages|tools)\/[a-z0-9-]+$/.test(entry.directory))
15
+ throw new Error(`worldengine_package_catalog_directory_invalid:${entry.name}`);
16
+ if (entry.runtime !== undefined && entry.runtime !== "browser")
17
+ throw new Error(`worldengine_package_catalog_runtime_invalid:${entry.name}`);
18
+ if (entry.build_dependencies !== undefined && (!Array.isArray(entry.build_dependencies) || entry.build_dependencies.some((name) => typeof name !== "string")))
19
+ throw new Error(`worldengine_package_catalog_build_dependencies_invalid:${entry.name}`);
20
+ if (names.has(entry.name) || directories.has(entry.directory))
21
+ throw new Error(`worldengine_package_catalog_duplicate:${entry.name}`);
22
+ names.add(entry.name);
23
+ directories.add(entry.directory);
24
+ }
25
+ for (const entry of catalog.packages) {
26
+ for (const dependency of entry.build_dependencies ?? [])
27
+ if (!names.has(dependency) || dependency === entry.name)
28
+ throw new Error(`worldengine_package_catalog_build_dependency_invalid:${entry.name}:${dependency}`);
29
+ }
30
+ return catalog;
31
+ }
32
+ export const WORLDENGINE_NODE_PACKAGE_CATALOG = Object.freeze(loadPackageCatalog().packages.map((entry) => Object.freeze({ ...entry })));
33
+ export const WORLDENGINE_NODE_PACKAGE_NAMES = Object.freeze(WORLDENGINE_NODE_PACKAGE_CATALOG.map(({ name }) => name));
@@ -0,0 +1,22 @@
1
+ export interface StandaloneSetupCmdReceiptV1 {
2
+ readonly schema_version: 1;
3
+ readonly output_file: string;
4
+ readonly release_identity: string;
5
+ readonly node_version: string;
6
+ readonly payload_archive_sha256: string;
7
+ readonly payload_archive_size: number;
8
+ readonly test_only: boolean;
9
+ }
10
+ export declare function inspectGuiStartup(input: {
11
+ readonly processRunning: boolean;
12
+ readonly stdout: string;
13
+ }): {
14
+ readonly ready: boolean;
15
+ readonly url?: string;
16
+ };
17
+ export declare function buildStandaloneSetupCmd({ payloadRoot, outputFile, testOnlySkipInstall, }: {
18
+ readonly payloadRoot: string;
19
+ readonly outputFile: string;
20
+ /** 只供隔离 fixture 验证 CMD 解包/执行链;生成的产物不得作为作者分发包。 */
21
+ readonly testOnlySkipInstall?: boolean;
22
+ }): Promise<StandaloneSetupCmdReceiptV1>;
@@ -0,0 +1,278 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { relative, resolve } from "node:path";
4
+ import { deflateRawSync } from "node:zlib";
5
+ import { inspectSetupPayload } from "./installer.js";
6
+ const PAYLOAD_MARKER = "# __WORLDENGINE_PAYLOAD__";
7
+ const POWERSHELL_MARKER = "# __WORLDENGINE_PS__";
8
+ export function inspectGuiStartup(input) {
9
+ if (!input.processRunning)
10
+ return { ready: false };
11
+ const match = /\{\s*"status"\s*:\s*"listening"\s*,\s*"url"\s*:\s*"([^"]+)"\s*\}\s*$/s.exec(input.stdout);
12
+ return match?.[1] === "http://127.0.0.1:11451/" ? { ready: true, url: match[1] } : { ready: false };
13
+ }
14
+ const CRC_TABLE = Array.from({ length: 256 }, (_, value) => {
15
+ let crc = value;
16
+ for (let bit = 0; bit < 8; bit += 1)
17
+ crc = (crc & 1) === 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
18
+ return crc >>> 0;
19
+ });
20
+ function crc32(bytes) {
21
+ let crc = 0xffffffff;
22
+ for (const byte of bytes)
23
+ crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
24
+ return (crc ^ 0xffffffff) >>> 0;
25
+ }
26
+ async function payloadFiles(root) {
27
+ const files = [];
28
+ async function visit(directory) {
29
+ const entries = (await readdir(directory, { withFileTypes: true }))
30
+ .sort((left, right) => left.name.localeCompare(right.name, "en"));
31
+ for (const entry of entries) {
32
+ const path = resolve(directory, entry.name);
33
+ const info = await lstat(path);
34
+ if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) {
35
+ throw new Error(`E_PAYLOAD_INVALID: 单文件 CMD payload 只允许普通文件和目录: ${path}`);
36
+ }
37
+ if (info.isDirectory()) {
38
+ await visit(path);
39
+ }
40
+ else {
41
+ files.push({ path: relative(root, path).replaceAll("\\", "/"), bytes: await readFile(path) });
42
+ }
43
+ }
44
+ }
45
+ await visit(root);
46
+ return files;
47
+ }
48
+ function zipPayload(files) {
49
+ const localParts = [];
50
+ const centralParts = [];
51
+ let offset = 0;
52
+ for (const file of files) {
53
+ const name = Buffer.from(file.path, "utf8");
54
+ const compressed = deflateRawSync(file.bytes, { level: 9 });
55
+ const crc = crc32(file.bytes);
56
+ const local = Buffer.alloc(30);
57
+ local.writeUInt32LE(0x04034b50, 0);
58
+ local.writeUInt16LE(20, 4);
59
+ local.writeUInt16LE(0x0800, 6);
60
+ local.writeUInt16LE(8, 8);
61
+ local.writeUInt32LE(crc, 14);
62
+ local.writeUInt32LE(compressed.length, 18);
63
+ local.writeUInt32LE(file.bytes.length, 22);
64
+ local.writeUInt16LE(name.length, 26);
65
+ localParts.push(local, name, compressed);
66
+ const central = Buffer.alloc(46);
67
+ central.writeUInt32LE(0x02014b50, 0);
68
+ central.writeUInt16LE(20, 4);
69
+ central.writeUInt16LE(20, 6);
70
+ central.writeUInt16LE(0x0800, 8);
71
+ central.writeUInt16LE(8, 10);
72
+ central.writeUInt32LE(crc, 16);
73
+ central.writeUInt32LE(compressed.length, 20);
74
+ central.writeUInt32LE(file.bytes.length, 24);
75
+ central.writeUInt16LE(name.length, 28);
76
+ central.writeUInt32LE(offset, 42);
77
+ centralParts.push(central, name);
78
+ offset += local.length + name.length + compressed.length;
79
+ }
80
+ if (files.length > 0xffff || offset > 0xffffffff)
81
+ throw new Error("E_PAYLOAD_TOO_LARGE: CMD payload 超过 ZIP32 上限");
82
+ const central = Buffer.concat(centralParts);
83
+ const end = Buffer.alloc(22);
84
+ end.writeUInt32LE(0x06054b50, 0);
85
+ end.writeUInt16LE(files.length, 8);
86
+ end.writeUInt16LE(files.length, 10);
87
+ end.writeUInt32LE(central.length, 12);
88
+ end.writeUInt32LE(offset, 16);
89
+ return Buffer.concat([...localParts, central, end]);
90
+ }
91
+ function powershellScript(releaseIdentity, nodeVersion, archiveSha256, testOnlySkipInstall) {
92
+ const release = JSON.stringify(releaseIdentity);
93
+ const version = JSON.stringify(nodeVersion);
94
+ const digest = JSON.stringify(archiveSha256);
95
+ return String.raw `
96
+ $ErrorActionPreference = 'Stop'
97
+ $ProgressPreference = 'SilentlyContinue'
98
+ $cmdPath = [IO.Path]::GetFullPath($env:WORLDENGINE_SETUP_CMD)
99
+ $target = [IO.Path]::GetFullPath($env:WORLDENGINE_SETUP_DIR)
100
+ $targetRoot = [IO.Path]::GetPathRoot($target)
101
+ if ($target.Length -gt $targetRoot.Length) { $target = $target.TrimEnd([IO.Path]::DirectorySeparatorChar) }
102
+ $entries = @(Get-ChildItem -LiteralPath $target -Force)
103
+ if ($entries.Count -ne 1 -or -not $entries[0].FullName.Equals($cmdPath, [StringComparison]::OrdinalIgnoreCase) -or $entries[0].PSIsContainer) {
104
+ throw 'E_TARGET_NOT_EMPTY: CMD 所在目录必须仅包含当前 CMD'
105
+ }
106
+ $targetParent = Split-Path -Parent $target
107
+ $targetName = Split-Path -Leaf $target
108
+ $temporaryIdentity = [guid]::NewGuid().ToString('N')
109
+ $temporaryRoot = Join-Path $targetParent ('.' + $targetName + '.worldengine-standalone-' + $temporaryIdentity)
110
+ New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
111
+ $temporaryOwner = Join-Path $temporaryRoot '.worldengine-cmd-owner'
112
+ [IO.File]::WriteAllText($temporaryOwner, $temporaryIdentity, [Text.Encoding]::ASCII)
113
+ try {
114
+ $source = [IO.File]::ReadAllText($cmdPath)
115
+ $marker = '${PAYLOAD_MARKER}' + [Environment]::NewLine
116
+ $markerIndex = $source.IndexOf($marker, [StringComparison]::Ordinal)
117
+ if ($markerIndex -lt 0) { throw 'E_PAYLOAD_INVALID: CMD 缺少 payload marker' }
118
+ $payloadBase64 = $source.Substring($markerIndex + $marker.Length) -replace '\s', ''
119
+ $archive = [Convert]::FromBase64String($payloadBase64)
120
+ $sha256 = [Security.Cryptography.SHA256]::Create()
121
+ try { $actualHash = ([BitConverter]::ToString($sha256.ComputeHash($archive))).Replace('-', '').ToLowerInvariant() } finally { $sha256.Dispose() }
122
+ if ($actualHash -ne ${digest}) { throw 'E_PAYLOAD_INVALID: CMD 内嵌 payload SHA-256 不匹配' }
123
+ $archivePath = Join-Path $temporaryRoot 'payload.zip'
124
+ $payloadRoot = Join-Path $temporaryRoot 'payload'
125
+ [IO.File]::WriteAllBytes($archivePath, $archive)
126
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
127
+ [IO.Compression.ZipFile]::ExtractToDirectory($archivePath, $payloadRoot)
128
+ $manifest = Get-Content -LiteralPath (Join-Path $payloadRoot 'setup-payload-manifest.json') -Raw | ConvertFrom-Json
129
+ if ($manifest.release_identity -ne ${release} -or $manifest.node.version -ne ${version}) {
130
+ throw 'E_PAYLOAD_INVALID: payload release identity 或 Node version 不匹配'
131
+ }
132
+ $runner = Join-Path $temporaryRoot 'run-standalone.mjs'
133
+ $runnerSource = @'
134
+ import { readFile } from "node:fs/promises";
135
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
136
+ import { pathToFileURL } from "node:url";
137
+ const [payloadRoot, target, bootstrapFile] = process.argv.slice(2);
138
+ const manifest = JSON.parse(await readFile(join(payloadRoot, "setup-payload-manifest.json"), "utf8"));
139
+ function payloadPath(path) {
140
+ if (typeof path !== "string" || isAbsolute(path)) throw new Error("E_PAYLOAD_INVALID: 非法 payload path");
141
+ const value = resolve(payloadRoot, path);
142
+ const remainder = relative(payloadRoot, value);
143
+ if (remainder === ".." || remainder.startsWith("../") || remainder.startsWith("..\\")) throw new Error("E_PAYLOAD_INVALID: payload path escape");
144
+ return value;
145
+ }
146
+ const createProject = payloadPath("bootstrap/node_modules/@world-engines/create-project/dist/index.js");
147
+ const { initializeLocalAuthorProject } = await import(pathToFileURL(createProject).href);
148
+ const node = payloadPath(manifest.node.path);
149
+ const receipt = await initializeLocalAuthorProject({
150
+ target,
151
+ bootstrap_file: bootstrapFile,
152
+ portable_runtime_directory: dirname(node),
153
+ ${testOnlySkipInstall ? " install_dependencies: false,\n verify: false,\n" : ""} npm_executable: node,
154
+ npm_arguments_prefix: [join(dirname(node), "node_modules", "npm", "bin", "npm-cli.js")],
155
+ package_source: {
156
+ tarball_directory: payloadPath(manifest.package_source.path),
157
+ tarball_directory_sha256: manifest.package_source.sha256,
158
+ },
159
+ });
160
+ process.stdout.write(JSON.stringify(receipt) + "\\n");
161
+ '@
162
+ [IO.File]::WriteAllText($runner, $runnerSource, [Text.UTF8Encoding]::new($false))
163
+ $payloadNode = [IO.Path]::GetFullPath((Join-Path $payloadRoot $manifest.node.path))
164
+ & $payloadNode $runner $payloadRoot $target $cmdPath
165
+ if ($LASTEXITCODE -ne 0) { throw "E_INITIALIZER_FAILED: initializer 退出码 $LASTEXITCODE" }
166
+ if ($env:WORLDENGINE_SETUP_NO_START -ne '1') {
167
+ $node = Join-Path $target '.worldengine\runtime\node\node.exe'
168
+ $hostCli = Join-Path $target 'node_modules\@world-engines\project-host\dist\cli.js'
169
+ if (-not (Test-Path -LiteralPath $hostCli -PathType Leaf)) { throw 'E_GUI_START_FAILED: 缺少已安装的 ProjectHost CLI' }
170
+ $guiArguments = @('"' + $hostCli + '"', 'gui')
171
+ if ($env:WORLDENGINE_SETUP_NO_OPEN -eq '1') { $guiArguments += '--no-open' }
172
+ $stdout = Join-Path $target '.worldengine\logs\gui-bootstrap.stdout.log'
173
+ $stderr = Join-Path $target '.worldengine\logs\gui-bootstrap.stderr.log'
174
+ try {
175
+ $process = Start-Process -FilePath $node -ArgumentList $guiArguments -WorkingDirectory $target -WindowStyle Hidden -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru
176
+ } catch {
177
+ throw 'E_GUI_START_FAILED: 无法启动项目 GUI 进程'
178
+ }
179
+ $deadline = [DateTime]::UtcNow.AddSeconds(20)
180
+ $readyUrl = $null
181
+ do {
182
+ $process.Refresh()
183
+ if ($process.HasExited) { break }
184
+ if (Test-Path -LiteralPath $stdout -PathType Leaf) {
185
+ $guiOutput = $null
186
+ try {
187
+ $stream = [IO.FileStream]::new($stdout, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite)
188
+ try {
189
+ $reader = [IO.StreamReader]::new($stream)
190
+ try { $guiOutput = $reader.ReadToEnd() } finally { $reader.Dispose() }
191
+ } finally { $stream.Dispose() }
192
+ } catch [IO.IOException] {
193
+ $guiOutput = $null
194
+ }
195
+ if ($guiOutput -eq $null) { Start-Sleep -Milliseconds 200; continue }
196
+ $match = [regex]::Match($guiOutput, '(?s)\{\s*"status"\s*:\s*"listening"\s*,\s*"url"\s*:\s*"(?<url>[^"]+)"\s*\}\s*$')
197
+ if ($match.Success -and $match.Groups['url'].Value -eq 'http://127.0.0.1:11451/') {
198
+ $readyUrl = $match.Groups['url'].Value
199
+ break
200
+ }
201
+ }
202
+ Start-Sleep -Milliseconds 200
203
+ } while ([DateTime]::UtcNow -lt $deadline)
204
+ $process.Refresh()
205
+ if ($readyUrl -eq $null -or $process.HasExited) {
206
+ if (-not $process.HasExited) { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue }
207
+ throw 'E_GUI_START_FAILED: GUI 未在 20 秒内返回 project-bound listening receipt;已安装项目保持不变'
208
+ }
209
+ $guiReceipt = [ordered]@{ schema_version = 1; status = 'ready'; process_id = $process.Id; url = $readyUrl; no_open = ($env:WORLDENGINE_SETUP_NO_OPEN -eq '1') }
210
+ $guiReceipt | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $target '.worldengine\operations\gui-bootstrap.json') -Encoding UTF8
211
+ }
212
+ } finally {
213
+ if ((Test-Path -LiteralPath $temporaryOwner -PathType Leaf) -and
214
+ ([IO.File]::ReadAllText($temporaryOwner) -eq $temporaryIdentity) -and
215
+ ((Split-Path -Parent $temporaryRoot).Equals($targetParent, [StringComparison]::OrdinalIgnoreCase))) {
216
+ Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
217
+ }
218
+ }
219
+ `;
220
+ }
221
+ function batchHeader(powerShell) {
222
+ const loader = `$content=[IO.File]::ReadAllText($env:WORLDENGINE_SETUP_CMD);$psMarker='${POWERSHELL_MARKER}'+[Environment]::NewLine;$payloadMarker='${PAYLOAD_MARKER}'+[Environment]::NewLine;$start=$content.IndexOf($psMarker,[StringComparison]::Ordinal);$end=$content.IndexOf($payloadMarker,[StringComparison]::Ordinal);if($start -lt 0 -or $end -le $start){throw 'E_PAYLOAD_INVALID: CMD markers'};&([scriptblock]::Create($content.Substring($start+$psMarker.Length,$end-$start-$psMarker.Length)))`;
223
+ const encodedLoader = Buffer.from(loader, "utf16le").toString("base64");
224
+ return `@echo off
225
+ setlocal EnableExtensions DisableDelayedExpansion
226
+ set "WORLDENGINE_SETUP_CMD=%~f0"
227
+ set "WORLDENGINE_SETUP_DIR=%~dp0"
228
+ set "WORLDENGINE_SETUP_NO_START=0"
229
+ set "WORLDENGINE_SETUP_NO_OPEN=0"
230
+ if not "%~1"=="" (
231
+ if /I "%~1"=="--no-start" (
232
+ set "WORLDENGINE_SETUP_NO_START=1"
233
+ ) else if /I "%~1"=="--no-open" (
234
+ set "WORLDENGINE_SETUP_NO_OPEN=1"
235
+ ) else (
236
+ echo E_ARGUMENT_UNKNOWN: %~1 1>&2
237
+ exit /b 2
238
+ )
239
+ )
240
+ if not "%~2"=="" (
241
+ if /I "%~2"=="--no-start" (
242
+ set "WORLDENGINE_SETUP_NO_START=1"
243
+ ) else if /I "%~2"=="--no-open" (
244
+ set "WORLDENGINE_SETUP_NO_OPEN=1"
245
+ ) else (
246
+ echo E_ARGUMENT_UNKNOWN: %~2 1>&2
247
+ exit /b 2
248
+ )
249
+ )
250
+ if not "%~3"=="" (
251
+ echo E_ARGUMENT_UNKNOWN: %~3 1>&2
252
+ exit /b 2
253
+ )
254
+ :run
255
+ powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedLoader}
256
+ exit /b %ERRORLEVEL%
257
+ ${POWERSHELL_MARKER}
258
+ ${powerShell.trimStart()}`.replaceAll("\n", "\r\n");
259
+ }
260
+ export async function buildStandaloneSetupCmd({ payloadRoot, outputFile, testOnlySkipInstall = false, }) {
261
+ const root = resolve(payloadRoot);
262
+ const output = resolve(outputFile);
263
+ const manifest = await inspectSetupPayload(root);
264
+ const archive = zipPayload(await payloadFiles(root));
265
+ const archiveSha256 = createHash("sha256").update(archive).digest("hex");
266
+ const base64 = archive.toString("base64").match(/.{1,76}/g)?.join("\r\n") ?? "";
267
+ const command = `${batchHeader(powershellScript(manifest.release_identity, manifest.node.version, archiveSha256, testOnlySkipInstall))}\r\n${PAYLOAD_MARKER}\r\n${base64}\r\n`;
268
+ await writeFile(output, command, { encoding: "utf8", flag: "wx" });
269
+ return {
270
+ schema_version: 1,
271
+ output_file: output,
272
+ release_identity: manifest.release_identity,
273
+ node_version: manifest.node.version,
274
+ payload_archive_sha256: archiveSha256,
275
+ payload_archive_size: archive.length,
276
+ test_only: testOnlySkipInstall,
277
+ };
278
+ }
@@ -0,0 +1,43 @@
1
+ import { type WorldEngineNodePackageName } from "./package-catalog.js";
2
+ /**
3
+ * payload 中唯一的 first-party package 闭包。这里是 release-stage 的输入边界,
4
+ * 不是 workspace package.json 的第二份依赖权威。
5
+ */
6
+ export declare const TOOLCHAIN_FIRST_PARTY_PACKAGES: readonly string[];
7
+ export type ToolchainFirstPartyPackage = WorldEngineNodePackageName;
8
+ export declare const TOOLCHAIN_MANIFEST_FILE = "toolchain-manifest.json";
9
+ export interface ToolchainPackageV1 {
10
+ readonly name: ToolchainFirstPartyPackage;
11
+ readonly version: string;
12
+ readonly archive: string;
13
+ readonly sha256: string;
14
+ readonly license_sha256: string;
15
+ /** build 前后读回一致的 package source postimage。 */
16
+ readonly source_sha256: string;
17
+ }
18
+ export interface ToolchainManifestV1 {
19
+ readonly schema_version: 1;
20
+ /** 所有 tarball(first-party 与其锁定的第三方)数量;payload 以此拒绝 closure 漂移。 */
21
+ readonly tarball_count: number;
22
+ readonly packages: readonly ToolchainPackageV1[];
23
+ }
24
+ export interface PackToolchainInput {
25
+ readonly output_directory: string;
26
+ readonly license_file: string;
27
+ readonly package_directories: Readonly<Record<ToolchainFirstPartyPackage, string>>;
28
+ readonly source_sha256_by_package: Readonly<Record<ToolchainFirstPartyPackage, string>>;
29
+ readonly catalog: Readonly<Record<string, string>>;
30
+ readonly npm_executable?: string;
31
+ }
32
+ /** 只读取 npm tarball 中的具名文件,避免把 archive 解到工作区。 */
33
+ export declare function readPackedFile(archive: Uint8Array, expectedPath: string): Uint8Array;
34
+ /** 只解析 npm tarball 中的 package/package.json,避免把 archive 解到工作区。 */
35
+ export declare function readPackedPackageManifest(archive: Uint8Array): Record<string, unknown>;
36
+ export declare function createToolchainManifest(sourceDirectory: string): Promise<ToolchainManifestV1>;
37
+ export declare function writeToolchainManifest(sourceDirectory: string): Promise<ToolchainManifestV1>;
38
+ export declare function readToolchainManifest(sourceDirectory: string): Promise<ToolchainManifestV1>;
39
+ /**
40
+ * 在隔离副本中投影 workspace/catalog,随后用 npm pack 生成可被非 pnpm 客户端消费的包。
41
+ * 这里不修改源码 package.json;catalog 仍只由根 pnpm-workspace.yaml 定义。
42
+ */
43
+ export declare function packFirstPartyToolchain(input: PackToolchainInput): Promise<ToolchainManifestV1>;
@@ -0,0 +1,223 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { copyFile, cp, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { gunzipSync } from "node:zlib";
7
+ import { WORLDENGINE_NODE_PACKAGE_NAMES } from "./package-catalog.js";
8
+ const execFileAsync = promisify(execFile);
9
+ /**
10
+ * payload 中唯一的 first-party package 闭包。这里是 release-stage 的输入边界,
11
+ * 不是 workspace package.json 的第二份依赖权威。
12
+ */
13
+ export const TOOLCHAIN_FIRST_PARTY_PACKAGES = WORLDENGINE_NODE_PACKAGE_NAMES;
14
+ export const TOOLCHAIN_MANIFEST_FILE = "toolchain-manifest.json";
15
+ function sha256(bytes) {
16
+ return createHash("sha256").update(bytes).digest("hex");
17
+ }
18
+ function packageSlug(name) {
19
+ return name.replace(/^@/, "").replaceAll("/", "-");
20
+ }
21
+ function assertProjectedSpecifier(specifier, dependency) {
22
+ if (typeof specifier !== "string" || specifier.length === 0) {
23
+ throw new Error(`toolchain_dependency_specifier_invalid:${dependency}`);
24
+ }
25
+ if (/^(?:workspace:|catalog:|file:|link:)/.test(specifier)) {
26
+ throw new Error(`toolchain_dependency_specifier_unprojected:${dependency}`);
27
+ }
28
+ }
29
+ function projectSpecifier(specifier, dependency, catalog, packageVersions) {
30
+ if (specifier === "catalog:") {
31
+ const resolved = catalog[dependency];
32
+ if (resolved === undefined)
33
+ throw new Error(`toolchain_catalog_dependency_missing:${dependency}`);
34
+ return resolved;
35
+ }
36
+ if (specifier === "workspace:*") {
37
+ const resolved = packageVersions[dependency];
38
+ if (resolved === undefined)
39
+ throw new Error(`toolchain_workspace_dependency_missing:${dependency}`);
40
+ return resolved;
41
+ }
42
+ if (typeof specifier === "string" && specifier.startsWith("workspace:")) {
43
+ const resolved = specifier.slice("workspace:".length);
44
+ if (/^(?:\^|~)?\d/.test(resolved))
45
+ return resolved;
46
+ throw new Error(`toolchain_workspace_dependency_invalid:${dependency}`);
47
+ }
48
+ assertProjectedSpecifier(specifier, dependency);
49
+ return specifier;
50
+ }
51
+ function projectDependencyBlock(value, catalog, packageVersions) {
52
+ if (value === undefined)
53
+ return undefined;
54
+ if (typeof value !== "object" || value === null || Array.isArray(value))
55
+ throw new Error("toolchain_dependency_block_invalid");
56
+ return Object.fromEntries(Object.entries(value).map(([name, specifier]) => [name, projectSpecifier(specifier, name, catalog, packageVersions)]));
57
+ }
58
+ function projectPackageManifest(value, catalog, packageVersions) {
59
+ if (typeof value !== "object" || value === null || Array.isArray(value))
60
+ throw new Error("toolchain_package_json_invalid");
61
+ const manifest = { ...value };
62
+ for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
63
+ const projected = projectDependencyBlock(manifest[field], catalog, packageVersions);
64
+ if (projected !== undefined)
65
+ manifest[field] = projected;
66
+ }
67
+ return manifest;
68
+ }
69
+ function tarOctal(bytes) {
70
+ const text = new TextDecoder().decode(bytes).replaceAll("\0", "").trim();
71
+ return text.length === 0 ? 0 : Number.parseInt(text, 8);
72
+ }
73
+ function tarString(bytes) {
74
+ return new TextDecoder().decode(bytes).replace(/\0.*$/s, "");
75
+ }
76
+ /** 只读取 npm tarball 中的具名文件,避免把 archive 解到工作区。 */
77
+ export function readPackedFile(archive, expectedPath) {
78
+ const tar = gunzipSync(archive);
79
+ for (let offset = 0; offset + 512 <= tar.length;) {
80
+ const header = tar.subarray(offset, offset + 512);
81
+ const name = tarString(header.subarray(0, 100));
82
+ const prefix = tarString(header.subarray(345, 500));
83
+ const path = prefix.length === 0 ? name : `${prefix}/${name}`;
84
+ const size = tarOctal(header.subarray(124, 136));
85
+ const contentStart = offset + 512;
86
+ if (path === expectedPath)
87
+ return new Uint8Array(tar.subarray(contentStart, contentStart + size));
88
+ offset = contentStart + Math.ceil(size / 512) * 512;
89
+ }
90
+ throw new Error(`toolchain_packed_file_missing:${expectedPath}`);
91
+ }
92
+ /** 只解析 npm tarball 中的 package/package.json,避免把 archive 解到工作区。 */
93
+ export function readPackedPackageManifest(archive) {
94
+ const parsed = JSON.parse(new TextDecoder().decode(readPackedFile(archive, "package/package.json")));
95
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
96
+ throw new Error("toolchain_packed_package_json_invalid");
97
+ return parsed;
98
+ }
99
+ function packageIdentity(manifest, archive, bytes) {
100
+ const name = manifest.name;
101
+ const version = manifest.version;
102
+ if (!TOOLCHAIN_FIRST_PARTY_PACKAGES.includes(name)) {
103
+ if (typeof name === "string" && /^@(?:chat|world-engines|worldengine)\//.test(name))
104
+ throw new Error(`toolchain_first_party_package_unexpected:${name}`);
105
+ return undefined;
106
+ }
107
+ if (manifest.private === true)
108
+ throw new Error(`toolchain_first_party_package_private:${String(name)}`);
109
+ if (typeof version !== "string" || version.length === 0)
110
+ throw new Error(`toolchain_package_version_invalid:${String(name)}`);
111
+ const sourceSha256 = manifest.worldengine_source_sha256;
112
+ if (typeof sourceSha256 !== "string" || !/^[a-f0-9]{64}$/.test(sourceSha256))
113
+ throw new Error(`toolchain_package_source_sha256_invalid:${String(name)}`);
114
+ for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
115
+ const block = manifest[field];
116
+ if (block === undefined)
117
+ continue;
118
+ if (typeof block !== "object" || block === null || Array.isArray(block))
119
+ throw new Error(`toolchain_dependency_block_invalid:${String(name)}:${field}`);
120
+ for (const [dependency, specifier] of Object.entries(block)) {
121
+ assertProjectedSpecifier(specifier, `${String(name)}:${dependency}`);
122
+ if (/^@(?:chat|world-engines|worldengine)\//.test(dependency) && !TOOLCHAIN_FIRST_PARTY_PACKAGES.includes(dependency)) {
123
+ throw new Error(`toolchain_first_party_dependency_missing:${String(name)}:${dependency}`);
124
+ }
125
+ }
126
+ }
127
+ const licenseBytes = readPackedFile(bytes, "package/LICENSE");
128
+ return { name: name, version, archive, sha256: sha256(bytes), license_sha256: sha256(licenseBytes), source_sha256: sourceSha256 };
129
+ }
130
+ export async function createToolchainManifest(sourceDirectory) {
131
+ const source = resolve(sourceDirectory);
132
+ const entries = await readdir(source, { withFileTypes: true });
133
+ const archives = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".tgz")).sort((left, right) => left.name.localeCompare(right.name, "en"));
134
+ if (archives.length === 0)
135
+ throw new Error("toolchain_tarball_closure_empty");
136
+ const inspected = await Promise.all(archives.map(async (entry) => {
137
+ const bytes = new Uint8Array(await readFile(join(source, entry.name)));
138
+ const packed = readPackedPackageManifest(bytes);
139
+ return packageIdentity(packed, entry.name, bytes);
140
+ }));
141
+ const packages = inspected.filter((item) => item !== undefined);
142
+ const names = new Set(packages.map(({ name }) => name));
143
+ for (const name of TOOLCHAIN_FIRST_PARTY_PACKAGES) {
144
+ if (!names.has(name))
145
+ throw new Error(`toolchain_first_party_package_missing:${name}`);
146
+ }
147
+ if (packages.length !== names.size)
148
+ throw new Error("toolchain_first_party_package_duplicate");
149
+ if (new Set(packages.map(({ license_sha256 }) => license_sha256)).size !== 1)
150
+ throw new Error("toolchain_package_license_drift");
151
+ return { schema_version: 1, tarball_count: archives.length, packages: packages.sort((left, right) => left.name.localeCompare(right.name, "en")) };
152
+ }
153
+ export async function writeToolchainManifest(sourceDirectory) {
154
+ const source = resolve(sourceDirectory);
155
+ const manifest = await createToolchainManifest(source);
156
+ await writeFile(join(source, TOOLCHAIN_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" });
157
+ return manifest;
158
+ }
159
+ export async function readToolchainManifest(sourceDirectory) {
160
+ const source = resolve(sourceDirectory);
161
+ const raw = JSON.parse(await readFile(join(source, TOOLCHAIN_MANIFEST_FILE), "utf8"));
162
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
163
+ throw new Error("toolchain_manifest_invalid");
164
+ const manifest = raw;
165
+ if (manifest.schema_version !== 1 || !Number.isSafeInteger(manifest.tarball_count) || manifest.tarball_count < TOOLCHAIN_FIRST_PARTY_PACKAGES.length || !Array.isArray(manifest.packages))
166
+ throw new Error("toolchain_manifest_invalid");
167
+ const actual = await createToolchainManifest(source);
168
+ if (JSON.stringify(actual) !== JSON.stringify(manifest))
169
+ throw new Error("toolchain_manifest_drift");
170
+ return manifest;
171
+ }
172
+ /**
173
+ * 在隔离副本中投影 workspace/catalog,随后用 npm pack 生成可被非 pnpm 客户端消费的包。
174
+ * 这里不修改源码 package.json;catalog 仍只由根 pnpm-workspace.yaml 定义。
175
+ */
176
+ export async function packFirstPartyToolchain(input) {
177
+ const output = resolve(input.output_directory);
178
+ await mkdir(output, { recursive: true });
179
+ if ((await readdir(output)).length !== 0)
180
+ throw new Error("toolchain_output_not_empty");
181
+ const suppliedNames = Object.keys(input.package_directories);
182
+ if (suppliedNames.length !== TOOLCHAIN_FIRST_PARTY_PACKAGES.length || TOOLCHAIN_FIRST_PARTY_PACKAGES.some((name) => !suppliedNames.includes(name))) {
183
+ throw new Error("toolchain_package_directories_incomplete");
184
+ }
185
+ if (Object.keys(input.source_sha256_by_package).length !== TOOLCHAIN_FIRST_PARTY_PACKAGES.length || TOOLCHAIN_FIRST_PARTY_PACKAGES.some((name) => !/^[a-f0-9]{64}$/.test(input.source_sha256_by_package[name] ?? ""))) {
186
+ throw new Error("toolchain_source_postimage_incomplete");
187
+ }
188
+ const packageVersions = {};
189
+ for (const [name, directory] of Object.entries(input.package_directories)) {
190
+ const source = JSON.parse(await readFile(join(resolve(directory), "package.json"), "utf8"));
191
+ if (source.name !== name || typeof source.version !== "string")
192
+ throw new Error(`toolchain_source_package_invalid:${name}`);
193
+ packageVersions[name] = source.version;
194
+ }
195
+ const staging = join(output, ".staging");
196
+ await mkdir(staging);
197
+ try {
198
+ for (const [name, directory] of Object.entries(input.package_directories)) {
199
+ const source = resolve(directory);
200
+ const projected = join(staging, packageSlug(name));
201
+ await cp(source, projected, { recursive: true, force: false, filter: (entry) => !relative(source, entry).split(/[/\\]/).includes("node_modules") });
202
+ const packageJson = projectPackageManifest(JSON.parse(await readFile(join(source, "package.json"), "utf8")), input.catalog, packageVersions);
203
+ if (packageJson.license !== "SEE LICENSE IN LICENSE")
204
+ throw new Error(`toolchain_package_license_invalid:${name}`);
205
+ packageJson.worldengine_source_sha256 = input.source_sha256_by_package[name];
206
+ await writeFile(join(projected, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
207
+ await copyFile(resolve(input.license_file), join(projected, "LICENSE"));
208
+ const executable = resolve(input.npm_executable ?? process.execPath);
209
+ const npmCli = join(dirname(executable), "node_modules", "npm", "bin", "npm-cli.js");
210
+ await execFileAsync(executable, [npmCli, "pack", "--ignore-scripts", "--pack-destination", output], { cwd: projected, windowsHide: true });
211
+ const generated = (await readdir(output)).filter((entry) => entry.endsWith(".tgz")).find((entry) => entry.startsWith(packageSlug(name)));
212
+ if (generated === undefined)
213
+ throw new Error(`toolchain_pack_missing:${name}`);
214
+ const archive = new Uint8Array(await readFile(join(output, generated)));
215
+ const destination = `${packageSlug(name)}-sha256-${sha256(archive)}.tgz`;
216
+ await rename(join(output, generated), join(output, destination));
217
+ }
218
+ return await writeToolchainManifest(output);
219
+ }
220
+ finally {
221
+ await rm(staging, { recursive: true, force: true });
222
+ }
223
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "schema_version": 1,
3
+ "packages": [
4
+ { "name": "@world-engines/blocks-editor", "directory": "packages/blocks-editor", "runtime": "browser" },
5
+ { "name": "@world-engines/ladybug-bridge", "directory": "packages/ladybug-bridge" },
6
+ { "name": "@world-engines/monaco-host", "directory": "packages/monaco-host", "runtime": "browser" },
7
+ { "name": "@world-engines/protocol-ts", "directory": "packages/protocol-ts" },
8
+ { "name": "@world-engines/scenario-author-source", "directory": "packages/scenario-author-source" },
9
+ { "name": "@world-engines/scenario-review-snapshot", "directory": "packages/scenario-review-snapshot" },
10
+ { "name": "@world-engines/tmw", "directory": "packages/tmw" },
11
+ { "name": "@world-engines/view-exposure", "directory": "packages/view-exposure" },
12
+ { "name": "@world-engines/chatplay-vite-plugin", "directory": "packages/chatplay-vite-plugin" },
13
+ { "name": "@world-engines/agent-kit", "directory": "packages/worldengine-agent-kit", "build_dependencies": ["@world-engines/authoring-bridge"] },
14
+ { "name": "@world-engines/authoring-bridge", "directory": "packages/worldengine-authoring-bridge" },
15
+ { "name": "@world-engines/authoring-ui", "directory": "packages/worldengine-authoring-ui", "runtime": "browser" },
16
+ { "name": "@world-engines/create-project", "directory": "packages/worldengine-create-project" },
17
+ { "name": "@world-engines/project-format", "directory": "packages/worldengine-project-format" },
18
+ { "name": "@world-engines/project-host", "directory": "packages/worldengine-project-host", "build_dependencies": ["@world-engines/authoring-ui"] },
19
+ { "name": "@world-engines/project-setup", "directory": "tools/worldengine-project-setup" },
20
+ { "name": "@world-engines/spatial-authoring", "directory": "packages/worldengine-spatial-authoring" },
21
+ { "name": "@world-engines/view-sdk", "directory": "packages/worldengine-view-sdk" }
22
+ ]
23
+ }