@world-engines/project-host 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.
- package/LICENSE +46 -0
- package/assets/gui/WELogo-title.svg +11 -0
- package/assets/gui/app.js +1326 -0
- package/assets/gui/index.html +118 -0
- package/assets/gui/local-preview-asset-urls.js +61 -0
- package/assets/gui/local-preview-bridge.js +393 -0
- package/assets/gui/spatial-template-panel.js +233 -0
- package/assets/gui/styles.css +115 -0
- package/assets/gui/terrain-render.js +120 -0
- package/assets/gui/workspace-status.css +17 -0
- package/assets/gui/workspace-status.js +255 -0
- package/assets/gui/world-map-globe.js +187 -0
- package/assets/gui/world-map-worker.js +62 -0
- package/assets/gui/world-map.css +30 -0
- package/assets/gui/world-map.js +2466 -0
- package/dist/account-model-proxy.d.ts +21 -0
- package/dist/account-model-proxy.js +470 -0
- package/dist/authoring-browser-runtime.d.ts +7 -0
- package/dist/authoring-browser-runtime.js +110 -0
- package/dist/authoring-ingestion.d.ts +38 -0
- package/dist/authoring-ingestion.js +410 -0
- package/dist/authoring-model-connection.d.ts +166 -0
- package/dist/authoring-model-connection.js +757 -0
- package/dist/chinese-lexical.d.ts +152 -0
- package/dist/chinese-lexical.js +555 -0
- package/dist/chromium-preview-browser-driver.d.ts +43 -0
- package/dist/chromium-preview-browser-driver.js +575 -0
- package/dist/cli/download.d.ts +12 -0
- package/dist/cli/download.js +50 -0
- package/dist/cli/dump.d.ts +32 -0
- package/dist/cli/dump.js +91 -0
- package/dist/cli/ingest.d.ts +49 -0
- package/dist/cli/ingest.js +225 -0
- package/dist/cli/review.d.ts +10 -0
- package/dist/cli/review.js +44 -0
- package/dist/cli/upload.d.ts +57 -0
- package/dist/cli/upload.js +112 -0
- package/dist/cli-command.d.ts +60 -0
- package/dist/cli-command.js +613 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +182 -0
- package/dist/desktop-authoring-runtime.d.ts +88 -0
- package/dist/desktop-authoring-runtime.js +1602 -0
- package/dist/desktop-sidecar.d.ts +11 -0
- package/dist/desktop-sidecar.js +143 -0
- package/dist/document-dump.d.ts +113 -0
- package/dist/document-dump.js +431 -0
- package/dist/document-graph-extractor.d.ts +125 -0
- package/dist/document-graph-extractor.js +588 -0
- package/dist/document-ingestion-journal.d.ts +68 -0
- package/dist/document-ingestion-journal.js +471 -0
- package/dist/document-ingestion.d.ts +183 -0
- package/dist/document-ingestion.js +976 -0
- package/dist/document-query-path-proof.d.ts +30 -0
- package/dist/document-query-path-proof.js +34 -0
- package/dist/document-query-path-renderer.d.ts +50 -0
- package/dist/document-query-path-renderer.js +75 -0
- package/dist/document-query-proof-paths.d.ts +22 -0
- package/dist/document-query-proof-paths.js +71 -0
- package/dist/document-query.d.ts +97 -0
- package/dist/document-query.js +1106 -0
- package/dist/document-relation-role.d.ts +14 -0
- package/dist/document-relation-role.js +86 -0
- package/dist/document-source.d.ts +107 -0
- package/dist/document-source.js +255 -0
- package/dist/embedding-cache.d.ts +35 -0
- package/dist/embedding-cache.js +317 -0
- package/dist/frozen-review-local-pipeline.d.ts +88 -0
- package/dist/frozen-review-local-pipeline.js +609 -0
- package/dist/frozen-review-source-capture.d.ts +15 -0
- package/dist/frozen-review-source-capture.js +551 -0
- package/dist/frozen-review-wasm-encryptor.d.ts +16 -0
- package/dist/frozen-review-wasm-encryptor.js +72 -0
- package/dist/frozen-review.d.ts +131 -0
- package/dist/frozen-review.js +802 -0
- package/dist/graph-traversal.d.ts +23 -0
- package/dist/graph-traversal.js +100 -0
- package/dist/gui/graph-layout-worker.js +1 -0
- package/dist/gui/graph-layout.wasm +0 -0
- package/dist/gui/graph.css +1 -0
- package/dist/gui/graph.js +76 -0
- package/dist/gui/plane-generation.js +112 -0
- package/dist/gui/plane-generator.js +310 -0
- package/dist/gui/plane-generator_bg.wasm +0 -0
- package/dist/gui/procedural-city.js +595 -0
- package/dist/gui/road-generation.js +129 -0
- package/dist/gui/road-parcels.js +1584 -0
- package/dist/gui/score.css +1 -0
- package/dist/gui/score.js +54 -0
- package/dist/gui/spatial-runtime.js +1231 -0
- package/dist/gui/spatial-templates.js +200 -0
- package/dist/gui/trigger.css +1 -0
- package/dist/gui/trigger.js +142 -0
- package/dist/harness-callback-transport.d.ts +17 -0
- package/dist/harness-callback-transport.js +128 -0
- package/dist/harness-document-ingestion.d.ts +77 -0
- package/dist/harness-document-ingestion.js +343 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +43 -0
- package/dist/ingestion-source-reconciliation.d.ts +88 -0
- package/dist/ingestion-source-reconciliation.js +253 -0
- package/dist/ladybug-runtime.d.ts +117 -0
- package/dist/ladybug-runtime.js +886 -0
- package/dist/local-account-embedding.d.ts +34 -0
- package/dist/local-account-embedding.js +294 -0
- package/dist/local-gallery.d.ts +35 -0
- package/dist/local-gallery.js +227 -0
- package/dist/local-gui-auth.d.ts +96 -0
- package/dist/local-gui-auth.js +703 -0
- package/dist/local-gui-contract.d.ts +53 -0
- package/dist/local-gui-contract.js +5 -0
- package/dist/local-gui-image-assets.d.ts +17 -0
- package/dist/local-gui-image-assets.js +96 -0
- package/dist/local-gui-preview-client.d.ts +3 -0
- package/dist/local-gui-preview-client.js +111 -0
- package/dist/local-gui-score.d.ts +72 -0
- package/dist/local-gui-score.js +184 -0
- package/dist/local-gui-server.d.ts +10 -0
- package/dist/local-gui-server.js +987 -0
- package/dist/local-gui-version.d.ts +17 -0
- package/dist/local-gui-version.js +147 -0
- package/dist/local-gui-workspace.d.ts +14 -0
- package/dist/local-gui-workspace.js +171 -0
- package/dist/local-model-settings.d.ts +28 -0
- package/dist/local-model-settings.js +124 -0
- package/dist/local-preview-assets.d.ts +28 -0
- package/dist/local-preview-assets.js +124 -0
- package/dist/local-preview-bound-resources.d.ts +12 -0
- package/dist/local-preview-bound-resources.js +80 -0
- package/dist/local-preview-message-store.d.ts +63 -0
- package/dist/local-preview-message-store.js +214 -0
- package/dist/local-preview-player-read-models.d.ts +47 -0
- package/dist/local-preview-player-read-models.js +90 -0
- package/dist/local-preview-projections.d.ts +61 -0
- package/dist/local-preview-projections.js +117 -0
- package/dist/local-preview-runtime.d.ts +105 -0
- package/dist/local-preview-runtime.js +553 -0
- package/dist/local-preview-session.d.ts +73 -0
- package/dist/local-preview-session.js +395 -0
- package/dist/local-preview-source.d.ts +40 -0
- package/dist/local-preview-source.js +192 -0
- package/dist/local-preview-view-events.d.ts +101 -0
- package/dist/local-preview-view-events.js +179 -0
- package/dist/local-preview-view-state-sqlite.d.ts +24 -0
- package/dist/local-preview-view-state-sqlite.js +263 -0
- package/dist/local-preview-view-state.d.ts +93 -0
- package/dist/local-preview-view-state.js +108 -0
- package/dist/local-preview-vite-config.d.ts +6 -0
- package/dist/local-preview-vite-config.js +122 -0
- package/dist/local-preview-world-effects.d.ts +9 -0
- package/dist/local-preview-world-effects.js +55 -0
- package/dist/local-preview-worldline-store.d.ts +47 -0
- package/dist/local-preview-worldline-store.js +282 -0
- package/dist/local-preview-worldlines.d.ts +95 -0
- package/dist/local-preview-worldlines.js +136 -0
- package/dist/mcp-async-dispatcher.d.ts +3 -0
- package/dist/mcp-async-dispatcher.js +129 -0
- package/dist/mcp-line-queue.d.ts +14 -0
- package/dist/mcp-line-queue.js +184 -0
- package/dist/mcp-pipe-internal.d.ts +6 -0
- package/dist/mcp-pipe-internal.js +151 -0
- package/dist/mcp-pipe-response.d.ts +14 -0
- package/dist/mcp-pipe-response.js +50 -0
- package/dist/mcp-stdio-response.d.ts +12 -0
- package/dist/mcp-stdio-response.js +27 -0
- package/dist/mcp.d.ts +27 -0
- package/dist/mcp.js +194 -0
- package/dist/prepared-embedding-cache.d.ts +38 -0
- package/dist/prepared-embedding-cache.js +238 -0
- package/dist/preview-browser-adapter.d.ts +53 -0
- package/dist/preview-browser-adapter.js +171 -0
- package/dist/preview-runtime.d.ts +67 -0
- package/dist/preview-runtime.js +460 -0
- package/dist/project-auth-transport.d.ts +27 -0
- package/dist/project-auth-transport.js +121 -0
- package/dist/project-auth.d.ts +51 -0
- package/dist/project-auth.js +108 -0
- package/dist/project-git-capture-recovery.d.ts +16 -0
- package/dist/project-git-capture-recovery.js +347 -0
- package/dist/project-git-cli.d.ts +5 -0
- package/dist/project-git-cli.js +40 -0
- package/dist/project-git-scenario-source.d.ts +10 -0
- package/dist/project-git-scenario-source.js +240 -0
- package/dist/project-git-view-source.d.ts +13 -0
- package/dist/project-git-view-source.js +134 -0
- package/dist/project-git.d.ts +97 -0
- package/dist/project-git.js +656 -0
- package/dist/project-host.d.ts +59 -0
- package/dist/project-host.js +471 -0
- package/dist/project-writer-lease.d.ts +18 -0
- package/dist/project-writer-lease.js +430 -0
- package/dist/public-error.d.ts +7 -0
- package/dist/public-error.js +61 -0
- package/dist/retrieval-benchmark.d.ts +46 -0
- package/dist/retrieval-benchmark.js +58 -0
- package/dist/safe-project-path.d.ts +8 -0
- package/dist/safe-project-path.js +93 -0
- package/dist/scenario-writer.d.ts +32 -0
- package/dist/scenario-writer.js +191 -0
- package/dist/transfer/download.d.ts +159 -0
- package/dist/transfer/download.js +598 -0
- package/dist/transfer/transport.d.ts +26 -0
- package/dist/transfer/transport.js +1 -0
- package/dist/transfer/upload.d.ts +162 -0
- package/dist/transfer/upload.js +354 -0
- package/dist/transfer-types.d.ts +95 -0
- package/dist/transfer-types.js +1 -0
- package/dist/transfer.d.ts +18 -0
- package/dist/transfer.js +273 -0
- package/dist/trigger-runtime.d.ts +121 -0
- package/dist/trigger-runtime.js +153 -0
- package/dist/vendor/wasm-core/asset-hashes.json +5 -0
- package/dist/vendor/wasm-core/wasm_core.d.ts +490 -0
- package/dist/vendor/wasm-core/wasm_core.js +1 -0
- package/dist/vendor/wasm-core/wasm_core_bg.wasm +0 -0
- package/dist/view-watcher.d.ts +9 -0
- package/dist/view-watcher.js +34 -0
- package/package.json +110 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { AuthoringBridgeError } from "@world-engines/authoring-bridge";
|
|
4
|
+
import { ensureSafeProjectDirectory, safeProjectPath } from "./safe-project-path.js";
|
|
5
|
+
function isInside(root, candidate) {
|
|
6
|
+
const path = relative(root, candidate);
|
|
7
|
+
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
8
|
+
}
|
|
9
|
+
function assertInside(root, candidate) {
|
|
10
|
+
if (!isInside(root, candidate)) {
|
|
11
|
+
throw new AuthoringBridgeError("E_PATH_OUTSIDE_PROJECT", "认证路径逃逸项目根");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** 将任意项目相对位置安全地解析到给定项目根内。 */
|
|
15
|
+
export function resolveProjectAuthPath(projectRoot, projectRelativePath) {
|
|
16
|
+
const root = resolve(projectRoot);
|
|
17
|
+
if (isAbsolute(projectRelativePath)) {
|
|
18
|
+
throw new AuthoringBridgeError("E_PATH_OUTSIDE_PROJECT", "认证路径必须是项目相对路径");
|
|
19
|
+
}
|
|
20
|
+
const candidate = resolve(root, projectRelativePath);
|
|
21
|
+
assertInside(root, candidate);
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
24
|
+
/** auth 与 WebView profile 固定在项目根,绝不退回到用户目录或共享 app data。 */
|
|
25
|
+
export function resolveProjectAuthPaths(projectRoot) {
|
|
26
|
+
const root = resolve(projectRoot);
|
|
27
|
+
return {
|
|
28
|
+
project_root: root,
|
|
29
|
+
auth_directory: resolveProjectAuthPath(root, ".worldengine/auth"),
|
|
30
|
+
webview_data_directory: resolveProjectAuthPath(root, ".worldengine/webview"),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 项目级 broker。它不保存会话凭据:没有可用的外部 adapter 时只能保持 signed_out。
|
|
35
|
+
*/
|
|
36
|
+
export class ProjectAuthBroker {
|
|
37
|
+
identity;
|
|
38
|
+
paths;
|
|
39
|
+
#adapter;
|
|
40
|
+
#state = "signed_out";
|
|
41
|
+
#challenge;
|
|
42
|
+
constructor(identity, paths, adapter) {
|
|
43
|
+
if (!identity.windows_user_id || !identity.project_id) {
|
|
44
|
+
throw new AuthoringBridgeError("E_SCHEMA_INVALID", "broker identity 缺少 Windows user 或 project ID");
|
|
45
|
+
}
|
|
46
|
+
this.identity = Object.freeze({ ...identity });
|
|
47
|
+
this.paths = paths;
|
|
48
|
+
this.#adapter = adapter;
|
|
49
|
+
}
|
|
50
|
+
static async open(projectRoot, identity, adapter) {
|
|
51
|
+
const requested = resolveProjectAuthPaths(projectRoot);
|
|
52
|
+
const canonicalRoot = await realpath(requested.project_root);
|
|
53
|
+
await ensureSafeProjectDirectory(canonicalRoot, ".worldengine");
|
|
54
|
+
const authDirectory = await ensureSafeProjectDirectory(canonicalRoot, ".worldengine/auth");
|
|
55
|
+
const webviewDataDirectory = await ensureSafeProjectDirectory(canonicalRoot, ".worldengine/webview");
|
|
56
|
+
await safeProjectPath(canonicalRoot, ".worldengine/auth");
|
|
57
|
+
await safeProjectPath(canonicalRoot, ".worldengine/webview");
|
|
58
|
+
return new ProjectAuthBroker(identity, {
|
|
59
|
+
project_root: canonicalRoot,
|
|
60
|
+
auth_directory: authDirectory,
|
|
61
|
+
webview_data_directory: webviewDataDirectory,
|
|
62
|
+
}, adapter);
|
|
63
|
+
}
|
|
64
|
+
get state() {
|
|
65
|
+
return this.#state;
|
|
66
|
+
}
|
|
67
|
+
async beginLogin() {
|
|
68
|
+
if (this.#adapter === undefined) {
|
|
69
|
+
throw new AuthoringBridgeError("E_AUTH_REQUIRED", "此项目没有可用的外部认证 adapter");
|
|
70
|
+
}
|
|
71
|
+
const challenge = await this.#adapter.beginChallenge(this.identity, this.paths.webview_data_directory);
|
|
72
|
+
if (!challenge.challenge_id || !challenge.challenge || challenge.expires_at_ms <= Date.now()) {
|
|
73
|
+
throw new AuthoringBridgeError("E_VALIDATION_FAILED", "外部认证 adapter 返回无效 challenge");
|
|
74
|
+
}
|
|
75
|
+
this.#challenge = Object.freeze({ ...challenge });
|
|
76
|
+
this.#state = "challenge_pending";
|
|
77
|
+
return this.#challenge;
|
|
78
|
+
}
|
|
79
|
+
async completeLogin(challengeId) {
|
|
80
|
+
if (this.#adapter === undefined || this.#challenge === undefined || this.#state !== "challenge_pending") {
|
|
81
|
+
throw new AuthoringBridgeError("E_AUTH_REQUIRED", "项目没有待完成的外部认证 challenge");
|
|
82
|
+
}
|
|
83
|
+
if (challengeId !== this.#challenge.challenge_id || this.#challenge.expires_at_ms <= Date.now()) {
|
|
84
|
+
this.#challenge = undefined;
|
|
85
|
+
this.#state = "signed_out";
|
|
86
|
+
throw new AuthoringBridgeError("E_AUTH_REQUIRED", "认证 challenge 不匹配或已过期");
|
|
87
|
+
}
|
|
88
|
+
await this.#adapter.completeChallenge(this.identity, this.#challenge, this.paths.webview_data_directory);
|
|
89
|
+
this.#challenge = undefined;
|
|
90
|
+
this.#state = "signed_in";
|
|
91
|
+
}
|
|
92
|
+
async logout() {
|
|
93
|
+
if (this.#adapter !== undefined && this.#state === "signed_in") {
|
|
94
|
+
await this.#adapter.logout(this.identity, this.paths.webview_data_directory);
|
|
95
|
+
}
|
|
96
|
+
this.#challenge = undefined;
|
|
97
|
+
this.#state = "signed_out";
|
|
98
|
+
}
|
|
99
|
+
/** 可安全记录的诊断;故意不包含 challenge、cookie、token 或 WebView 内容。 */
|
|
100
|
+
diagnostic() {
|
|
101
|
+
return {
|
|
102
|
+
project_id: this.identity.project_id,
|
|
103
|
+
auth_state: this.#state,
|
|
104
|
+
auth_directory: this.paths.auth_directory,
|
|
105
|
+
webview_data_directory: this.paths.webview_data_directory,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type GitCaptureRepositoryKind = "map" | "database" | "view";
|
|
2
|
+
/** 仅供崩溃注入测试使用;生产调用不传入该参数。 */
|
|
3
|
+
export interface GitCaptureRecoveryDependencies {
|
|
4
|
+
/** 仅测试:返回 crash 模拟宿主在该 filesystem 原子操作后消失。 */
|
|
5
|
+
readonly fault?: (phase: "after_old_entry" | "after_old_moved" | "after_new_entry" | "after_new_moved") => "crash" | void | Promise<"crash" | void>;
|
|
6
|
+
readonly isPidLive?: (pid: number) => boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* 恢复未完成的仓根替换。只移动 journal 中记录、且内容摘要仍匹配的条目;任何旁路编辑均 fail closed。
|
|
10
|
+
*/
|
|
11
|
+
export declare function recoverGitSourceTree(root: string, kindValue: GitCaptureRepositoryKind, dependencies?: GitCaptureRecoveryDependencies): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* 将 capture source 原子地换入固定的三仓根,同时把旧 source 留在受控 backup 中。
|
|
14
|
+
* .git 从不移动。错误时尝试立即恢复;测试可模拟进程崩溃留下 journal。
|
|
15
|
+
*/
|
|
16
|
+
export declare function replaceGitSourceTree(root: string, repositoryPath: string, stagingPath: string, kindValue: GitCaptureRepositoryKind, dependencies?: GitCaptureRecoveryDependencies): Promise<void>;
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, open, readFile, readdir, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { AuthoringBridgeError } from "@world-engines/authoring-bridge";
|
|
5
|
+
import { assertSafeExistingProjectPath, ensureSafeProjectDirectory, safeProjectPath } from "./safe-project-path.js";
|
|
6
|
+
const CONTROL = ".worldengine/project-git";
|
|
7
|
+
const KINDS = new Set(["map", "database", "view"]);
|
|
8
|
+
const TOP_LEVEL_NAME = /^(?!\.git$)(?!\.worldengine$)[^\\/\0]+$/u;
|
|
9
|
+
class InjectedCrash extends Error {
|
|
10
|
+
}
|
|
11
|
+
function fail(message) {
|
|
12
|
+
throw new AuthoringBridgeError("E_OPERATION_UNCERTAIN", message);
|
|
13
|
+
}
|
|
14
|
+
function assertKind(kind) {
|
|
15
|
+
if (!KINDS.has(kind))
|
|
16
|
+
fail("Git capture recovery repository kind 无效");
|
|
17
|
+
}
|
|
18
|
+
function assertEntryName(name) {
|
|
19
|
+
if (typeof name !== "string" || !TOP_LEVEL_NAME.test(name) || name === "." || name === "..") {
|
|
20
|
+
fail("Git capture recovery 检测到不安全的仓库根条目");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function recoveryHint(root, kind) {
|
|
24
|
+
return `${join(root, CONTROL, `${kind}-capture-journal.json`)};请保留 ${join(root, CONTROL)} 下的 capture/source-backup 目录后恢复`;
|
|
25
|
+
}
|
|
26
|
+
function treeEntryMap(entries) {
|
|
27
|
+
return new Map(entries.map((entry) => [entry.name, entry.digest]));
|
|
28
|
+
}
|
|
29
|
+
function sameTree(left, right) {
|
|
30
|
+
if (left.length !== right.length)
|
|
31
|
+
return false;
|
|
32
|
+
const expected = treeEntryMap(left);
|
|
33
|
+
return right.every((entry) => expected.get(entry.name) === entry.digest);
|
|
34
|
+
}
|
|
35
|
+
async function digestEntry(path) {
|
|
36
|
+
const info = await lstat(path);
|
|
37
|
+
if (info.isSymbolicLink())
|
|
38
|
+
fail(`Git capture recovery 拒绝符号链接:${path}`);
|
|
39
|
+
const hash = createHash("sha256");
|
|
40
|
+
if (info.isFile()) {
|
|
41
|
+
hash.update("file\0");
|
|
42
|
+
hash.update(await readFile(path));
|
|
43
|
+
return hash.digest("hex");
|
|
44
|
+
}
|
|
45
|
+
if (!info.isDirectory())
|
|
46
|
+
fail(`Git capture recovery 只允许普通文件或目录:${path}`);
|
|
47
|
+
hash.update("directory\0");
|
|
48
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
49
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
50
|
+
if (entry.name === ".git")
|
|
51
|
+
fail(`Git capture recovery 不允许嵌套 Git 控制目录:${path}`);
|
|
52
|
+
const child = join(path, entry.name);
|
|
53
|
+
const childInfo = await lstat(child);
|
|
54
|
+
if (childInfo.isSymbolicLink())
|
|
55
|
+
fail(`Git capture recovery 拒绝符号链接:${child}`);
|
|
56
|
+
hash.update(entry.name, "utf8");
|
|
57
|
+
hash.update("\0");
|
|
58
|
+
hash.update(await digestEntry(child));
|
|
59
|
+
hash.update("\0");
|
|
60
|
+
}
|
|
61
|
+
return hash.digest("hex");
|
|
62
|
+
}
|
|
63
|
+
async function listTree(path, allowGitControl = false) {
|
|
64
|
+
const output = [];
|
|
65
|
+
for (const entry of (await readdir(path, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
66
|
+
if (allowGitControl && entry.name === ".git")
|
|
67
|
+
continue;
|
|
68
|
+
assertEntryName(entry.name);
|
|
69
|
+
const absolute = join(path, entry.name);
|
|
70
|
+
output.push({ name: entry.name, digest: await digestEntry(absolute) });
|
|
71
|
+
}
|
|
72
|
+
return output;
|
|
73
|
+
}
|
|
74
|
+
async function exists(path) {
|
|
75
|
+
try {
|
|
76
|
+
await lstat(path);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.code === "ENOENT")
|
|
81
|
+
return false;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function isLivePid(pid) {
|
|
86
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
87
|
+
return false;
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, 0);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
return error.code === "EPERM";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function canonicalPaths(root, kind, repositoryPath, stagingPath) {
|
|
97
|
+
const control = await ensureSafeProjectDirectory(root, CONTROL);
|
|
98
|
+
const repository = await assertSafeExistingProjectPath(root, join(control, "repositories", kind));
|
|
99
|
+
const expectedRepository = resolve(control, "repositories", kind);
|
|
100
|
+
if (repository !== expectedRepository)
|
|
101
|
+
fail("Git capture recovery 仓库路径不在固定 project-git repositories 目录");
|
|
102
|
+
if (repositoryPath !== undefined && resolve(repositoryPath) !== repository)
|
|
103
|
+
fail("Git capture recovery 拒绝非固定仓库根");
|
|
104
|
+
if (stagingPath === undefined)
|
|
105
|
+
return { control, repository };
|
|
106
|
+
const staging = await assertSafeExistingProjectPath(root, stagingPath);
|
|
107
|
+
const stagedRelative = relative(control, staging);
|
|
108
|
+
if (stagedRelative === "" || stagedRelative.startsWith(`..${sep}`) || stagedRelative === ".." || !/^capture-[A-Za-z0-9-]+$/u.test(stagedRelative)) {
|
|
109
|
+
fail("Git capture recovery 暂存目录必须是 project-git/capture-* 控制目录");
|
|
110
|
+
}
|
|
111
|
+
return { control, repository, staging };
|
|
112
|
+
}
|
|
113
|
+
function journalRelative(kind) { return `${CONTROL}/${kind}-capture-journal.json`; }
|
|
114
|
+
async function writeJournal(root, journal) {
|
|
115
|
+
const control = await ensureSafeProjectDirectory(root, CONTROL);
|
|
116
|
+
const target = await safeProjectPath(root, journalRelative(journal.kind));
|
|
117
|
+
const temporary = join(control, `.capture-journal-${randomUUID()}.tmp`);
|
|
118
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
119
|
+
try {
|
|
120
|
+
await handle.writeFile(`${JSON.stringify(journal)}\n`, "utf8");
|
|
121
|
+
await handle.sync();
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await handle.close();
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await rename(temporary, target);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await rm(temporary, { force: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function parseEntries(value, field) {
|
|
134
|
+
if (!Array.isArray(value))
|
|
135
|
+
fail(`Git capture journal ${field} 无效`);
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
return value.map((entry) => {
|
|
138
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
139
|
+
fail(`Git capture journal ${field} 条目无效`);
|
|
140
|
+
const record = entry;
|
|
141
|
+
if (Object.keys(record).length !== 2 || typeof record.name !== "string" || typeof record.digest !== "string")
|
|
142
|
+
fail(`Git capture journal ${field} 条目字段无效`);
|
|
143
|
+
assertEntryName(record.name);
|
|
144
|
+
if (!/^[a-f0-9]{64}$/u.test(record.digest) || seen.has(record.name))
|
|
145
|
+
fail(`Git capture journal ${field} 条目无效`);
|
|
146
|
+
seen.add(record.name);
|
|
147
|
+
return { name: record.name, digest: record.digest };
|
|
148
|
+
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
149
|
+
}
|
|
150
|
+
function parseJournal(value, kind) {
|
|
151
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
152
|
+
fail("Git capture journal 无效");
|
|
153
|
+
const record = value;
|
|
154
|
+
const keys = ["schema_version", "kind", "pid", "repository_relative", "staging_relative", "backup_relative", "phase", "old_entries", "new_entries"];
|
|
155
|
+
if (Object.keys(record).length !== keys.length || Object.keys(record).some((key) => !keys.includes(key)))
|
|
156
|
+
fail("Git capture journal 字段无效");
|
|
157
|
+
if (record.schema_version !== 1 || record.kind !== kind || !Number.isSafeInteger(record.pid) || (typeof record.repository_relative !== "string") || typeof record.staging_relative !== "string" || typeof record.backup_relative !== "string" || !["prepared", "old_moved", "new_moving", "complete"].includes(String(record.phase))) {
|
|
158
|
+
fail("Git capture journal identity 无效");
|
|
159
|
+
}
|
|
160
|
+
const expectedRepository = `${CONTROL}/repositories/${kind}`;
|
|
161
|
+
if (record.repository_relative !== expectedRepository || !/^\.worldengine\/project-git\/capture-[A-Za-z0-9-]+$/u.test(record.staging_relative) || !/^\.worldengine\/project-git\/source-backup-[A-Za-z0-9-]+$/u.test(record.backup_relative)) {
|
|
162
|
+
fail("Git capture journal 路径无效");
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
schema_version: 1, kind, pid: record.pid, repository_relative: record.repository_relative,
|
|
166
|
+
staging_relative: record.staging_relative, backup_relative: record.backup_relative,
|
|
167
|
+
phase: record.phase, old_entries: parseEntries(record.old_entries, "old_entries"), new_entries: parseEntries(record.new_entries, "new_entries"),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
async function readJournal(root, kind) {
|
|
171
|
+
const path = await safeProjectPath(root, journalRelative(kind));
|
|
172
|
+
try {
|
|
173
|
+
return parseJournal(JSON.parse(await readFile(path, "utf8")), kind);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error.code === "ENOENT")
|
|
177
|
+
return undefined;
|
|
178
|
+
if (error instanceof AuthoringBridgeError)
|
|
179
|
+
throw error;
|
|
180
|
+
fail("Git capture journal JSON 无效");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function entryDigestIfPresent(path) {
|
|
184
|
+
if (!(await exists(path)))
|
|
185
|
+
return undefined;
|
|
186
|
+
return digestEntry(path);
|
|
187
|
+
}
|
|
188
|
+
async function assertExpectedSubset(path, expected, allowGitControl, hint) {
|
|
189
|
+
const allowed = new Map();
|
|
190
|
+
for (const entry of expected) {
|
|
191
|
+
const digests = allowed.get(entry.name) ?? new Set();
|
|
192
|
+
digests.add(entry.digest);
|
|
193
|
+
allowed.set(entry.name, digests);
|
|
194
|
+
}
|
|
195
|
+
for (const actual of await listTree(path, allowGitControl)) {
|
|
196
|
+
if (!allowed.get(actual.name)?.has(actual.digest))
|
|
197
|
+
fail(`Git capture recovery 发现未知或被编辑的字节,拒绝覆盖;恢复材料:${hint}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function moveExpected(from, to, entry, hint) {
|
|
201
|
+
const source = join(from, entry.name);
|
|
202
|
+
const destination = join(to, entry.name);
|
|
203
|
+
const sourceDigest = await entryDigestIfPresent(source);
|
|
204
|
+
const destinationDigest = await entryDigestIfPresent(destination);
|
|
205
|
+
if (sourceDigest === undefined) {
|
|
206
|
+
if (destinationDigest === entry.digest)
|
|
207
|
+
return;
|
|
208
|
+
fail(`Git capture recovery 缺少可验证的 ${entry.name};恢复材料:${hint}`);
|
|
209
|
+
}
|
|
210
|
+
if (sourceDigest !== entry.digest || destinationDigest !== undefined) {
|
|
211
|
+
fail(`Git capture recovery ${entry.name} 与 journal 摘要不匹配;恢复材料:${hint}`);
|
|
212
|
+
}
|
|
213
|
+
await rename(source, destination);
|
|
214
|
+
}
|
|
215
|
+
async function removeJournal(root, kind) {
|
|
216
|
+
await rm(await safeProjectPath(root, journalRelative(kind)), { force: true });
|
|
217
|
+
}
|
|
218
|
+
async function rollback(root, journal, paths) {
|
|
219
|
+
const hint = recoveryHint(root, journal.kind);
|
|
220
|
+
await assertExpectedSubset(paths.repository, [...journal.old_entries, ...journal.new_entries], true, hint);
|
|
221
|
+
await assertExpectedSubset(paths.staging, journal.new_entries, false, hint);
|
|
222
|
+
await assertExpectedSubset(paths.backup, journal.old_entries, false, hint);
|
|
223
|
+
const oldByName = treeEntryMap(journal.old_entries);
|
|
224
|
+
for (const entry of journal.new_entries) {
|
|
225
|
+
const repositoryDigest = await entryDigestIfPresent(join(paths.repository, entry.name));
|
|
226
|
+
const stagingDigest = await entryDigestIfPresent(join(paths.staging, entry.name));
|
|
227
|
+
if (repositoryDigest === entry.digest) {
|
|
228
|
+
if (stagingDigest === undefined)
|
|
229
|
+
await moveExpected(paths.repository, paths.staging, entry, hint);
|
|
230
|
+
else if (stagingDigest !== entry.digest || oldByName.get(entry.name) !== entry.digest)
|
|
231
|
+
fail(`Git capture recovery ${entry.name} 出现双份新树;恢复材料:${hint}`);
|
|
232
|
+
}
|
|
233
|
+
else if (repositoryDigest === undefined || repositoryDigest === oldByName.get(entry.name)) {
|
|
234
|
+
if (stagingDigest !== entry.digest)
|
|
235
|
+
fail(`Git capture recovery ${entry.name} 新树位置不一致;恢复材料:${hint}`);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
fail(`Git capture recovery ${entry.name} 仓根摘要不匹配;恢复材料:${hint}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const entry of journal.old_entries) {
|
|
242
|
+
const backupDigest = await entryDigestIfPresent(join(paths.backup, entry.name));
|
|
243
|
+
const repositoryDigest = await entryDigestIfPresent(join(paths.repository, entry.name));
|
|
244
|
+
if (backupDigest === entry.digest) {
|
|
245
|
+
if (repositoryDigest === undefined)
|
|
246
|
+
await moveExpected(paths.backup, paths.repository, entry, hint);
|
|
247
|
+
else if (repositoryDigest !== entry.digest)
|
|
248
|
+
fail(`Git capture recovery ${entry.name} 旧树目标被占用;恢复材料:${hint}`);
|
|
249
|
+
}
|
|
250
|
+
else if (repositoryDigest !== entry.digest) {
|
|
251
|
+
fail(`Git capture recovery 缺少可验证的旧树 ${entry.name};恢复材料:${hint}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!sameTree(journal.old_entries, await listTree(paths.repository, true)) || !sameTree(journal.new_entries, await listTree(paths.staging))) {
|
|
255
|
+
fail(`Git capture recovery 回滚后摘要不匹配;恢复材料:${hint}`);
|
|
256
|
+
}
|
|
257
|
+
await removeJournal(root, journal.kind);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* 恢复未完成的仓根替换。只移动 journal 中记录、且内容摘要仍匹配的条目;任何旁路编辑均 fail closed。
|
|
261
|
+
*/
|
|
262
|
+
export async function recoverGitSourceTree(root, kindValue, dependencies = {}) {
|
|
263
|
+
assertKind(kindValue);
|
|
264
|
+
const journal = await readJournal(root, kindValue);
|
|
265
|
+
if (journal === undefined)
|
|
266
|
+
return;
|
|
267
|
+
if ((dependencies.isPidLive ?? isLivePid)(journal.pid))
|
|
268
|
+
fail(`Git capture recovery 正由 pid ${journal.pid} 执行;恢复材料:${recoveryHint(root, kindValue)}`);
|
|
269
|
+
const base = await canonicalPaths(root, kindValue);
|
|
270
|
+
const staging = await assertSafeExistingProjectPath(root, join(root, journal.staging_relative));
|
|
271
|
+
const backup = await assertSafeExistingProjectPath(root, join(root, journal.backup_relative));
|
|
272
|
+
const paths = { repository: base.repository, staging, backup };
|
|
273
|
+
const hint = recoveryHint(root, kindValue);
|
|
274
|
+
// 进程在写入 complete 后崩溃时,目标树已经是新 source;不得回滚它。
|
|
275
|
+
if (journal.phase === "complete" || (journal.phase === "new_moving"
|
|
276
|
+
&& sameTree(journal.new_entries, await listTree(paths.repository, true))
|
|
277
|
+
&& sameTree(journal.old_entries, await listTree(paths.backup)))) {
|
|
278
|
+
if (!sameTree(journal.new_entries, await listTree(paths.repository, true))
|
|
279
|
+
|| !sameTree(journal.old_entries, await listTree(paths.backup))
|
|
280
|
+
|| (await listTree(paths.staging)).length !== 0)
|
|
281
|
+
fail(`Git capture recovery 完成态材料已变化;恢复材料:${hint}`);
|
|
282
|
+
await removeJournal(root, kindValue);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
await rollback(root, journal, paths);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* 将 capture source 原子地换入固定的三仓根,同时把旧 source 留在受控 backup 中。
|
|
289
|
+
* .git 从不移动。错误时尝试立即恢复;测试可模拟进程崩溃留下 journal。
|
|
290
|
+
*/
|
|
291
|
+
export async function replaceGitSourceTree(root, repositoryPath, stagingPath, kindValue, dependencies = {}) {
|
|
292
|
+
assertKind(kindValue);
|
|
293
|
+
await recoverGitSourceTree(root, kindValue, dependencies);
|
|
294
|
+
const paths = await canonicalPaths(root, kindValue, repositoryPath, stagingPath);
|
|
295
|
+
const repository = paths.repository;
|
|
296
|
+
const staging = paths.staging;
|
|
297
|
+
if (!(await exists(join(repository, ".git"))))
|
|
298
|
+
fail("Git capture recovery 固定仓库根缺少 .git");
|
|
299
|
+
const backupRelative = `${CONTROL}/source-backup-${randomUUID()}`;
|
|
300
|
+
const backup = await ensureSafeProjectDirectory(root, backupRelative);
|
|
301
|
+
const journal = {
|
|
302
|
+
schema_version: 1, kind: kindValue, pid: process.pid,
|
|
303
|
+
repository_relative: `${CONTROL}/repositories/${kindValue}`,
|
|
304
|
+
staging_relative: relative(root, staging).split(sep).join("/"), backup_relative: backupRelative,
|
|
305
|
+
phase: "prepared", old_entries: await listTree(repository, true), new_entries: await listTree(staging),
|
|
306
|
+
};
|
|
307
|
+
await writeJournal(root, journal);
|
|
308
|
+
const hint = recoveryHint(root, kindValue);
|
|
309
|
+
try {
|
|
310
|
+
for (const entry of journal.old_entries) {
|
|
311
|
+
await moveExpected(repository, backup, entry, hint);
|
|
312
|
+
if (await dependencies.fault?.("after_old_entry") === "crash")
|
|
313
|
+
throw new InjectedCrash("simulated capture crash");
|
|
314
|
+
}
|
|
315
|
+
const oldMoved = { ...journal, phase: "old_moved" };
|
|
316
|
+
await writeJournal(root, oldMoved);
|
|
317
|
+
if (await dependencies.fault?.("after_old_moved") === "crash")
|
|
318
|
+
throw new InjectedCrash("simulated capture crash");
|
|
319
|
+
const newMoving = { ...oldMoved, phase: "new_moving" };
|
|
320
|
+
await writeJournal(root, newMoving);
|
|
321
|
+
for (const entry of journal.new_entries) {
|
|
322
|
+
await moveExpected(staging, repository, entry, hint);
|
|
323
|
+
if (await dependencies.fault?.("after_new_entry") === "crash")
|
|
324
|
+
throw new InjectedCrash("simulated capture crash");
|
|
325
|
+
}
|
|
326
|
+
if (await dependencies.fault?.("after_new_moved") === "crash")
|
|
327
|
+
throw new InjectedCrash("simulated capture crash");
|
|
328
|
+
await writeJournal(root, { ...newMoving, phase: "complete" });
|
|
329
|
+
await removeJournal(root, kindValue);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (error instanceof InjectedCrash)
|
|
333
|
+
throw error;
|
|
334
|
+
try {
|
|
335
|
+
const current = await readJournal(root, kindValue);
|
|
336
|
+
if (current !== undefined) {
|
|
337
|
+
const liveBypass = { isPidLive: (pid) => pid !== process.pid && (dependencies.isPidLive ?? isLivePid)(pid) };
|
|
338
|
+
await recoverGitSourceTree(root, kindValue, liveBypass);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (recoveryError) {
|
|
342
|
+
// rollback 无法证明安全时必须返回带恢复路径的 fail-closed 错误。
|
|
343
|
+
throw recoveryError;
|
|
344
|
+
}
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ProjectTransferTransport } from "./transfer/transport.js";
|
|
2
|
+
export interface ProjectGitCliIo {
|
|
3
|
+
readonly stdout: (text: string) => void;
|
|
4
|
+
}
|
|
5
|
+
export declare function runProjectGitCli(args: readonly string[], projectRoot: string, transport: ProjectTransferTransport | undefined, io: ProjectGitCliIo): Promise<number>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ProjectGitClient, projectGitTransport } from "./project-git.js";
|
|
2
|
+
import { AuthoringBridgeError } from "@world-engines/authoring-bridge";
|
|
3
|
+
function writeJson(io, value) {
|
|
4
|
+
io.stdout(`${JSON.stringify(value, null, 2)}\n`);
|
|
5
|
+
}
|
|
6
|
+
export async function runProjectGitCli(args, projectRoot, transport, io) {
|
|
7
|
+
const [command, operand, ...rest] = args;
|
|
8
|
+
if (command === undefined || command === "help" || command === "--help") {
|
|
9
|
+
io.stdout("worldengine git manifest pull|push | git clone|fetch|push|capture|prepare-apply|apply map|database|view\n导出:capture → 在固定旁仓 git add/commit → push;应用:prepare-apply → fetch → 检查/合并提交 → apply。不会自动 stage、merge 或覆盖本地漂移。\n");
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
if (rest.length !== 0)
|
|
13
|
+
throw new AuthoringBridgeError("E_SCHEMA_INVALID", "git 参数无效");
|
|
14
|
+
const client = await ProjectGitClient.open(projectRoot, transport === undefined ? undefined : projectGitTransport(transport));
|
|
15
|
+
if (command === "manifest" && operand === "pull") {
|
|
16
|
+
writeJson(io, await client.pullManifest());
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
if (command === "manifest" && operand === "push") {
|
|
20
|
+
writeJson(io, await client.pushManifest());
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
if (command === "clone" && operand !== undefined)
|
|
24
|
+
writeJson(io, await client.clone(operand));
|
|
25
|
+
else if (command === "fetch" && operand !== undefined)
|
|
26
|
+
writeJson(io, await client.fetch(operand));
|
|
27
|
+
else if (command === "push" && operand !== undefined)
|
|
28
|
+
writeJson(io, await client.push(operand));
|
|
29
|
+
else if (command === "capture" && operand !== undefined)
|
|
30
|
+
writeJson(io, await client.capture(operand));
|
|
31
|
+
else if (command === "prepare-apply" && operand !== undefined)
|
|
32
|
+
writeJson(io, await client.prepareApply(operand));
|
|
33
|
+
else if (command === "apply" && operand !== undefined)
|
|
34
|
+
writeJson(io, await client.apply(operand));
|
|
35
|
+
else if (command === "inspect" && operand !== undefined)
|
|
36
|
+
writeJson(io, await client.inspect(operand));
|
|
37
|
+
else
|
|
38
|
+
throw new AuthoringBridgeError("E_SCHEMA_INVALID", "git 命令无效");
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type ScenarioGitSourceKind = "map" | "database";
|
|
2
|
+
export interface ScenarioGitCapture {
|
|
3
|
+
readonly files: Readonly<Record<string, Uint8Array>>;
|
|
4
|
+
/** 选定 Git tree 的 canonical SHA-256,而非整个 Local Author Project。 */
|
|
5
|
+
readonly digest: string;
|
|
6
|
+
readonly project_revision: number;
|
|
7
|
+
readonly source_sha256: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function captureScenarioGitSource(root: string, kind: ScenarioGitSourceKind): Promise<ScenarioGitCapture>;
|
|
10
|
+
export declare function applyScenarioGitSource(root: string, kind: ScenarioGitSourceKind, files: Readonly<Record<string, Uint8Array>>, expected: ScenarioGitCapture): Promise<void>;
|