code-workspace-zhuiyi 0.1.0-beta.1
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/.env.example +3 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/README.zh-CN.md +168 -0
- package/artifacts/manifest.json +183 -0
- package/artifacts/templates/USER_GUIDE.template.md +1 -0
- package/artifacts/templates/agents/WORKSPACE_GUARD.md.template +73 -0
- package/artifacts/templates/agents/skills/code-workspace-resolve-branch/SKILL.md +88 -0
- package/artifacts/templates/agents/skills/code-workspace-resolve-branch/evals/evals.json +45 -0
- package/artifacts/templates/claude/commands/code-workspace/add-projects.md +23 -0
- package/artifacts/templates/codex/hooks.json +86 -0
- package/artifacts/templates/codex/skills/code-workspace-add-projects/SKILL.md +28 -0
- package/artifacts/templates/user-guide/en-US.md +97 -0
- package/artifacts/templates/user-guide/zh-CN.md +97 -0
- package/assets/i18n-icon.svg +1 -0
- package/assets/logo-vector.svg +30 -0
- package/assets/request_tip.mp3 +0 -0
- package/assets/session_finish.mp3 +0 -0
- package/bin/code-workspace.js +11 -0
- package/docs/extension-architecture.zh-CN.md +429 -0
- package/docs/extensions.md +64 -0
- package/docs/extensions.zh-CN.md +64 -0
- package/extensions/openspec-workspace/1.0.0/artifacts/claude/SKILL.md +16 -0
- package/extensions/openspec-workspace/1.0.0/artifacts/codex/SKILL.md +16 -0
- package/extensions/openspec-workspace/1.0.0/init.js +54 -0
- package/extensions/openspec-workspace/1.0.0/manifest.json +27 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/artifacts/claude/server.json +14 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/artifacts/codex/config.toml +11 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/init.js +53 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/lib/archive.js +196 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/manifest.json +38 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/release.json +15 -0
- package/package.json +58 -0
- package/schemas/extension-init-context-v1.json +39 -0
- package/schemas/extension-init-result-v1.json +42 -0
- package/schemas/extension-manifest-v1.json +55 -0
- package/schemas/extension-manifest-v2.json +84 -0
- package/schemas/extension-manifest-v3.json +84 -0
- package/spec/extension/v1/specification.en-US.md +171 -0
- package/spec/extension/v1/specification.zh-CN.md +170 -0
- package/src/cli/commands/completion.js +203 -0
- package/src/cli/commands/extension.js +193 -0
- package/src/cli/commands/help.js +36 -0
- package/src/cli/commands/init.js +200 -0
- package/src/cli/commands/monitor.js +62 -0
- package/src/cli/commands/permissions.js +33 -0
- package/src/cli/commands/project-branch-update.js +107 -0
- package/src/cli/commands/project-branch.js +433 -0
- package/src/cli/commands/project.js +259 -0
- package/src/cli/commands/update.js +146 -0
- package/src/cli/commands/workspace.js +24 -0
- package/src/cli/confirmation.js +22 -0
- package/src/cli/parser.js +108 -0
- package/src/cli/registry.js +108 -0
- package/src/cli/renderer.js +20 -0
- package/src/cli/result.js +118 -0
- package/src/cli.js +76 -0
- package/src/core/assets.js +83 -0
- package/src/core/config.js +378 -0
- package/src/core/diagnostics.js +20 -0
- package/src/core/directory-digest.js +34 -0
- package/src/core/doctor.js +115 -0
- package/src/core/errors.js +10 -0
- package/src/core/extension-artifacts-legacy.js +180 -0
- package/src/core/extension-artifacts.js +310 -0
- package/src/core/extensions.js +1216 -0
- package/src/core/fs.js +32 -0
- package/src/core/init-lock-holder.js +30 -0
- package/src/core/init-lock.js +159 -0
- package/src/core/init.js +160 -0
- package/src/core/initializer.js +233 -0
- package/src/core/language.js +102 -0
- package/src/core/managed-files.js +334 -0
- package/src/core/migration.js +68 -0
- package/src/core/permissions/claude.js +92 -0
- package/src/core/permissions/codex.js +126 -0
- package/src/core/permissions/common.js +44 -0
- package/src/core/permissions/index.js +163 -0
- package/src/core/project-branch-update.js +424 -0
- package/src/core/project-configuration.js +55 -0
- package/src/core/project.js +674 -0
- package/src/core/tools.js +34 -0
- package/src/core/transaction.js +132 -0
- package/src/core/validation.js +186 -0
- package/src/i18n/index.js +11 -0
- package/src/i18n/interpolate.js +7 -0
- package/src/i18n/locales/en-US.js +10 -0
- package/src/i18n/locales/zh-CN.js +11 -0
- package/src/i18n/registry.js +42 -0
- package/src/index.js +25 -0
- package/src/init/plan.js +12 -0
- package/src/init/ui.js +61 -0
- package/src/init/wizard.js +97 -0
- package/src/monitor/i18n/index.js +33 -0
- package/src/monitor/i18n/locales/en-US.js +85 -0
- package/src/monitor/i18n/locales/zh-CN.js +79 -0
- package/src/monitor/index.js +344 -0
- package/src/monitor/page.js +118 -0
|
@@ -0,0 +1,1216 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { spawnSync } = require("node:child_process");
|
|
5
|
+
|
|
6
|
+
const { OBSOLETE_ASSETS } = require("./assets");
|
|
7
|
+
const { CONFIG_FILE, LOCAL_DIRECTORY, STATE_FILE, configPath } = require("./config");
|
|
8
|
+
const { WorkspaceError } = require("./errors");
|
|
9
|
+
const { atomicWrite, sha256 } = require("./fs");
|
|
10
|
+
const { loadManagedManifest } = require("./managed-files");
|
|
11
|
+
const { permissionTargets } = require("./permissions");
|
|
12
|
+
const { createFileTransaction } = require("./transaction");
|
|
13
|
+
const { directoryDigest } = require("./directory-digest");
|
|
14
|
+
const {
|
|
15
|
+
ARTIFACT_KINDS,
|
|
16
|
+
LEGACY_ARTIFACT_KINDS,
|
|
17
|
+
directoryArtifacts,
|
|
18
|
+
installedArtifactsCurrent,
|
|
19
|
+
installedRecord,
|
|
20
|
+
planArtifactTransition,
|
|
21
|
+
removeEmptyParents,
|
|
22
|
+
targetForArtifact,
|
|
23
|
+
verifyArtifactTransition,
|
|
24
|
+
} = require("./extension-artifacts");
|
|
25
|
+
|
|
26
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
|
|
27
|
+
const EXTENSIONS_ROOT = path.join(PACKAGE_ROOT, "extensions");
|
|
28
|
+
const EXTENSION_STATE_FILE = "ext-manifest.json";
|
|
29
|
+
const EXTENSION_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
30
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
31
|
+
const SUPPORTED_TOOLS = new Set(["claude", "codex"]);
|
|
32
|
+
const SUPPORTED_EXTENSION_SPEC_VERSIONS = Object.freeze([1]);
|
|
33
|
+
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
34
|
+
|
|
35
|
+
function extensionError(code, message, details = {}) {
|
|
36
|
+
return new WorkspaceError(code, message, details);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseSemver(value) {
|
|
40
|
+
const text = String(value || "").trim();
|
|
41
|
+
const match = text.match(SEMVER_PATTERN);
|
|
42
|
+
if (!match) throw extensionError("EXTENSION_SEMVER_INVALID", `Invalid semantic version: ${value}`, { version: value });
|
|
43
|
+
const prerelease = match[4] ? match[4].split(".") : [];
|
|
44
|
+
for (const identifier of prerelease) {
|
|
45
|
+
if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) {
|
|
46
|
+
throw extensionError("EXTENSION_SEMVER_INVALID", `Invalid semantic version: ${value}`, { version: value });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
raw: text,
|
|
51
|
+
major: Number(match[1]),
|
|
52
|
+
minor: Number(match[2]),
|
|
53
|
+
patch: Number(match[3]),
|
|
54
|
+
prerelease,
|
|
55
|
+
build: match[5] ? match[5].split(".") : [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function compareIdentifiers(left, right) {
|
|
60
|
+
const leftNumeric = /^\d+$/.test(left);
|
|
61
|
+
const rightNumeric = /^\d+$/.test(right);
|
|
62
|
+
if (leftNumeric && rightNumeric) return Number(left) - Number(right);
|
|
63
|
+
if (leftNumeric) return -1;
|
|
64
|
+
if (rightNumeric) return 1;
|
|
65
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function compareSemver(leftValue, rightValue) {
|
|
69
|
+
const left = typeof leftValue === "string" ? parseSemver(leftValue) : leftValue;
|
|
70
|
+
const right = typeof rightValue === "string" ? parseSemver(rightValue) : rightValue;
|
|
71
|
+
for (const key of ["major", "minor", "patch"]) {
|
|
72
|
+
if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1;
|
|
73
|
+
}
|
|
74
|
+
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
|
75
|
+
return left.prerelease.length === right.prerelease.length ? 0 : left.prerelease.length === 0 ? 1 : -1;
|
|
76
|
+
}
|
|
77
|
+
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
|
78
|
+
for (let index = 0; index < length; index += 1) {
|
|
79
|
+
if (left.prerelease[index] === undefined) return -1;
|
|
80
|
+
if (right.prerelease[index] === undefined) return 1;
|
|
81
|
+
const compared = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
|
|
82
|
+
if (compared !== 0) return compared < 0 ? -1 : 1;
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function supportsExtensionSpec(version, supportedVersions = SUPPORTED_EXTENSION_SPEC_VERSIONS) {
|
|
88
|
+
return Number.isInteger(version) && supportedVersions.includes(version);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validateExtensionName(value, label = "extension") {
|
|
92
|
+
const name = String(value || "");
|
|
93
|
+
if (!EXTENSION_NAME_PATTERN.test(name)) {
|
|
94
|
+
throw extensionError("EXTENSION_NAME_INVALID", `Invalid ${label} name: ${name || "<missing>"}`, { extension: name || null });
|
|
95
|
+
}
|
|
96
|
+
return name;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeArtifactTarget(value) {
|
|
100
|
+
const target = String(value || "");
|
|
101
|
+
const segments = target.split("/");
|
|
102
|
+
if (
|
|
103
|
+
!target || target.includes("\\") || path.posix.isAbsolute(target) ||
|
|
104
|
+
segments.some((segment) => !segment || segment === "." || segment === "..") ||
|
|
105
|
+
path.posix.normalize(target) !== target
|
|
106
|
+
) {
|
|
107
|
+
throw extensionError("EXTENSION_ARTIFACT_TARGET_INVALID", `Invalid extension artifact target: ${target || "<missing>"}`, { target: target || null });
|
|
108
|
+
}
|
|
109
|
+
return target;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function targetPathsOverlap(left, right) {
|
|
113
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function selectorPathsOverlap(left, right) {
|
|
117
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function artifactOwnership(artifact) {
|
|
121
|
+
const kind = artifact.kind || "file";
|
|
122
|
+
return ["file", "directory"].includes(kind) ? "exclusive" : "shared";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sharedArtifactFamily(artifact) {
|
|
126
|
+
const kind = artifact.kind || "file";
|
|
127
|
+
if (["text-block", "codex-config-block"].includes(kind)) return "text";
|
|
128
|
+
if (kind === "json-member") return "json";
|
|
129
|
+
return kind;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function artifactsConflict(left, right) {
|
|
133
|
+
if (!targetPathsOverlap(left.target, right.target)) return false;
|
|
134
|
+
if (left.target !== right.target) return true;
|
|
135
|
+
if (artifactOwnership(left) === "exclusive" || artifactOwnership(right) === "exclusive") return true;
|
|
136
|
+
const leftFamily = sharedArtifactFamily(left);
|
|
137
|
+
const rightFamily = sharedArtifactFamily(right);
|
|
138
|
+
if (leftFamily !== rightFamily) return true;
|
|
139
|
+
if (leftFamily === "json") return selectorPathsOverlap(left.selector, right.selector);
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function targetIsProtected(target, protectedTargets, exclusive) {
|
|
144
|
+
return exclusive
|
|
145
|
+
? [...protectedTargets].some((protectedTarget) => targetPathsOverlap(target, protectedTarget))
|
|
146
|
+
: protectedTargets.has(target);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function coreWholeFileTargets() {
|
|
150
|
+
const manifest = loadManagedManifest();
|
|
151
|
+
return new Set([
|
|
152
|
+
`${LOCAL_DIRECTORY}/${CONFIG_FILE}`,
|
|
153
|
+
`${LOCAL_DIRECTORY}/${STATE_FILE}`,
|
|
154
|
+
`${LOCAL_DIRECTORY}/${EXTENSION_STATE_FILE}`,
|
|
155
|
+
".gitignore",
|
|
156
|
+
...manifest.managedFiles.map((entry) => entry.target),
|
|
157
|
+
...OBSOLETE_ASSETS,
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function coreManagedTargets() {
|
|
162
|
+
const root = path.parse(PACKAGE_ROOT).root;
|
|
163
|
+
return new Set([
|
|
164
|
+
...coreWholeFileTargets(),
|
|
165
|
+
...permissionTargets(root, ["claude", "codex"]).map((target) => path.relative(root, target).split(path.sep).join("/")),
|
|
166
|
+
]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function assertOnlyKeys(value, allowed, code, label) {
|
|
170
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
171
|
+
if (unknown.length > 0) throw extensionError(code, `${label} contains unsupported field ${unknown[0]}`, { field: unknown[0] });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validateOutputTools(value, id, outputId) {
|
|
175
|
+
if (value === undefined) return null;
|
|
176
|
+
if (!Array.isArray(value) || value.length === 0 || new Set(value).size !== value.length || value.some((tool) => !SUPPORTED_TOOLS.has(tool))) {
|
|
177
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} output ${outputId} has invalid tools`, { extension: id, output: outputId });
|
|
178
|
+
}
|
|
179
|
+
return Object.freeze(value.slice());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function validateNetworkHosts(value, id) {
|
|
183
|
+
if (value === undefined) return Object.freeze([]);
|
|
184
|
+
if (!Array.isArray(value) || value.length === 0 || new Set(value).size !== value.length) {
|
|
185
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} has invalid networkHosts`, { extension: id });
|
|
186
|
+
}
|
|
187
|
+
const hosts = value.map((entry) => String(entry || ""));
|
|
188
|
+
for (const host of hosts) {
|
|
189
|
+
let parsed;
|
|
190
|
+
try { parsed = new URL(`https://${host}`); } catch { throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} has invalid network host ${host || "<missing>"}`, { extension: id, host: host || null }); }
|
|
191
|
+
if (!host || host !== host.toLowerCase() || parsed.hostname !== host || parsed.port || parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
192
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} has invalid network host ${host || "<missing>"}`, { extension: id, host: host || null });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return Object.freeze(hosts);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validateManifestEnvelope(value, options = {}) {
|
|
199
|
+
const manifest = value;
|
|
200
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
201
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", "Extension manifest must be a JSON object");
|
|
202
|
+
}
|
|
203
|
+
if (!Number.isInteger(manifest.extensionSpecVersion) || manifest.extensionSpecVersion < 1) {
|
|
204
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", "Extension manifest requires a positive integer extensionSpecVersion", { extensionSpecVersion: manifest.extensionSpecVersion ?? null });
|
|
205
|
+
}
|
|
206
|
+
const id = validateExtensionName(manifest.id);
|
|
207
|
+
if (options.expectedId && id !== options.expectedId) {
|
|
208
|
+
throw extensionError("EXTENSION_MANIFEST_ID_MISMATCH", `Extension manifest id ${id} does not match directory ${options.expectedId}`, { extension: id, expected: options.expectedId });
|
|
209
|
+
}
|
|
210
|
+
if (typeof manifest.name !== "string" || !manifest.name.trim() || [...manifest.name.trim()].length > 100) {
|
|
211
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} has an invalid display name`, { extension: id });
|
|
212
|
+
}
|
|
213
|
+
const version = parseSemver(manifest.version).raw;
|
|
214
|
+
if (options.expectedVersion && version !== options.expectedVersion) {
|
|
215
|
+
throw extensionError("EXTENSION_MANIFEST_VERSION_MISMATCH", `Extension manifest version ${version} does not match directory ${options.expectedVersion}`, { extension: id, version, expected: options.expectedVersion });
|
|
216
|
+
}
|
|
217
|
+
return Object.freeze({
|
|
218
|
+
extensionSpecVersion: manifest.extensionSpecVersion,
|
|
219
|
+
id,
|
|
220
|
+
name: manifest.name.trim(),
|
|
221
|
+
version,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function validateManifest(value, options = {}) {
|
|
226
|
+
const manifest = value;
|
|
227
|
+
const envelope = validateManifestEnvelope(manifest, options);
|
|
228
|
+
const supportedVersions = options.supportedExtensionSpecVersions || SUPPORTED_EXTENSION_SPEC_VERSIONS;
|
|
229
|
+
if (!supportsExtensionSpec(envelope.extensionSpecVersion, supportedVersions)) {
|
|
230
|
+
throw extensionError("EXTENSION_SPEC_UNSUPPORTED", `Extension ${envelope.id}@${envelope.version} requires unsupported Extension Spec ${envelope.extensionSpecVersion}`, {
|
|
231
|
+
extension: envelope.id,
|
|
232
|
+
version: envelope.version,
|
|
233
|
+
extensionSpecVersion: envelope.extensionSpecVersion,
|
|
234
|
+
supportedExtensionSpecVersions: [...supportedVersions],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
assertOnlyKeys(manifest, new Set(["schemaVersion", "extensionSpecVersion", "experimental", "id", "name", "version", "entry", "entrySha256", "timeoutMs", "capabilities", "outputs"]), "EXTENSION_MANIFEST_INVALID", "Extension manifest");
|
|
238
|
+
if (manifest.schemaVersion !== 3 || manifest.experimental !== true) {
|
|
239
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", "Extension Spec v1 manifest must use schemaVersion 3 and experimental true");
|
|
240
|
+
}
|
|
241
|
+
const { id, version } = envelope;
|
|
242
|
+
if (manifest.entry !== "init.js") {
|
|
243
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} entry must be init.js`, { extension: id, entry: manifest.entry });
|
|
244
|
+
}
|
|
245
|
+
if (!SHA256_PATTERN.test(manifest.entrySha256 || "")) {
|
|
246
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} entrySha256 is invalid`, { extension: id });
|
|
247
|
+
}
|
|
248
|
+
if (!Number.isInteger(manifest.timeoutMs) || manifest.timeoutMs < 1 || manifest.timeoutMs > 300000) {
|
|
249
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} timeoutMs must be an integer from 1 to 300000`, { extension: id, timeoutMs: manifest.timeoutMs });
|
|
250
|
+
}
|
|
251
|
+
const capabilities = manifest.capabilities === undefined ? {} : manifest.capabilities;
|
|
252
|
+
if (!capabilities || typeof capabilities !== "object" || Array.isArray(capabilities)) {
|
|
253
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} capabilities must be an object`, { extension: id });
|
|
254
|
+
}
|
|
255
|
+
assertOnlyKeys(capabilities, new Set(["networkHosts"]), "EXTENSION_MANIFEST_INVALID", `Extension ${id} capabilities`);
|
|
256
|
+
const networkHosts = validateNetworkHosts(capabilities.networkHosts, id);
|
|
257
|
+
if (!Array.isArray(manifest.outputs) || manifest.outputs.length === 0) {
|
|
258
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} must declare at least one output`, { extension: id });
|
|
259
|
+
}
|
|
260
|
+
const ids = new Set();
|
|
261
|
+
const declaredArtifacts = [];
|
|
262
|
+
const exclusiveProtectedTargets = options.protectedTargets || coreManagedTargets();
|
|
263
|
+
const sharedProtectedTargets = options.protectedTargets || coreWholeFileTargets();
|
|
264
|
+
const outputs = manifest.outputs.map((output) => {
|
|
265
|
+
if (!output || typeof output !== "object" || Array.isArray(output)) {
|
|
266
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} contains an invalid output`, { extension: id });
|
|
267
|
+
}
|
|
268
|
+
assertOnlyKeys(output, new Set(["id", "kind", "ownership", "target", "selector", "format", "tools"]), "EXTENSION_MANIFEST_INVALID", `Extension ${id} output`);
|
|
269
|
+
const outputId = validateExtensionName(output.id, "output");
|
|
270
|
+
const kind = String(output.kind || "");
|
|
271
|
+
if (!ARTIFACT_KINDS.has(kind)) throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} output ${outputId} has unsupported kind ${kind || "<missing>"}`, { extension: id, output: outputId, kind: kind || null });
|
|
272
|
+
const ownership = String(output.ownership || "");
|
|
273
|
+
const expectedOwnership = ["file", "directory"].includes(kind) ? "exclusive" : "shared";
|
|
274
|
+
if (ownership !== expectedOwnership) {
|
|
275
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} output ${outputId} must use ${expectedOwnership} ownership`, { extension: id, output: outputId, kind, ownership: ownership || null });
|
|
276
|
+
}
|
|
277
|
+
const target = normalizeArtifactTarget(output.target);
|
|
278
|
+
const protectedTargets = expectedOwnership === "exclusive" ? exclusiveProtectedTargets : sharedProtectedTargets;
|
|
279
|
+
if (targetIsProtected(target, protectedTargets, expectedOwnership === "exclusive")) throw extensionError("EXTENSION_CORE_TARGET_FORBIDDEN", `Extension ${id} cannot target Code Workspace core path ${target}`, { extension: id, output: outputId, target });
|
|
280
|
+
if (ids.has(outputId)) throw extensionError("EXTENSION_ARTIFACT_DUPLICATE", `Extension ${id} repeats output id ${outputId}`, { extension: id, output: outputId });
|
|
281
|
+
let selector;
|
|
282
|
+
if (kind === "json-member") {
|
|
283
|
+
selector = String(output.selector || "");
|
|
284
|
+
if (!selector.startsWith("/") || selector === "/" || selector.split("/").slice(1).some((segment) => !segment || /~(?![01])/.test(segment))) {
|
|
285
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} output ${outputId} has invalid JSON selector`, { extension: id, output: outputId, selector: selector || null });
|
|
286
|
+
}
|
|
287
|
+
} else if (output.selector !== undefined) {
|
|
288
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} output ${outputId} cannot declare selector for ${kind}`, { extension: id, output: outputId, kind });
|
|
289
|
+
}
|
|
290
|
+
let format;
|
|
291
|
+
if (kind === "text-block") {
|
|
292
|
+
format = output.format === undefined ? "text" : String(output.format);
|
|
293
|
+
if (!["text", "toml"].includes(format)) throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} output ${outputId} has unsupported text format ${format}`, { extension: id, output: outputId, format });
|
|
294
|
+
} else if (output.format !== undefined) {
|
|
295
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} output ${outputId} cannot declare format for ${kind}`, { extension: id, output: outputId, kind });
|
|
296
|
+
}
|
|
297
|
+
const tools = validateOutputTools(output.tools, id, outputId);
|
|
298
|
+
const normalized = { id: outputId, kind, ownership, target, ...(selector ? { selector } : {}), ...(format ? { format } : {}), ...(tools ? { tools } : {}) };
|
|
299
|
+
const conflicting = declaredArtifacts.find((artifact) => artifactsConflict(artifact, normalized));
|
|
300
|
+
if (conflicting) {
|
|
301
|
+
throw extensionError("EXTENSION_TARGET_CONFLICT", `Extension ${id} outputs ${conflicting.id} and ${outputId} have overlapping ownership`, {
|
|
302
|
+
extension: id,
|
|
303
|
+
output: outputId,
|
|
304
|
+
conflictingOutput: conflicting.id,
|
|
305
|
+
target,
|
|
306
|
+
...(selector ? { selector } : {}),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
ids.add(outputId);
|
|
310
|
+
declaredArtifacts.push(normalized);
|
|
311
|
+
return Object.freeze(normalized);
|
|
312
|
+
});
|
|
313
|
+
return Object.freeze({
|
|
314
|
+
schemaVersion: 3,
|
|
315
|
+
extensionSpecVersion: envelope.extensionSpecVersion,
|
|
316
|
+
experimental: true,
|
|
317
|
+
id,
|
|
318
|
+
name: envelope.name,
|
|
319
|
+
version,
|
|
320
|
+
entry: manifest.entry,
|
|
321
|
+
entrySha256: manifest.entrySha256,
|
|
322
|
+
timeoutMs: manifest.timeoutMs,
|
|
323
|
+
capabilities: Object.freeze({ networkHosts }),
|
|
324
|
+
outputs: Object.freeze(outputs),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function readJson(file, code) {
|
|
329
|
+
try {
|
|
330
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw extensionError(code, `Cannot parse ${file}: ${error.message}`, { file });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function assertRegularFile(file, code, label) {
|
|
337
|
+
let stat;
|
|
338
|
+
try {
|
|
339
|
+
stat = fs.lstatSync(file);
|
|
340
|
+
} catch {
|
|
341
|
+
throw extensionError(code, `Missing ${label}: ${file}`, { file });
|
|
342
|
+
}
|
|
343
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw extensionError(code, `${label} must be a regular file: ${file}`, { file });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function discoverExtensionEntry(extensionsRoot, extensionEntry, supportedExtensionSpecVersions, options = {}) {
|
|
347
|
+
if (!extensionEntry.isDirectory() || extensionEntry.isSymbolicLink()) {
|
|
348
|
+
throw extensionError("EXTENSION_REPOSITORY_INVALID", `Extension repository entry must be a directory: ${extensionEntry.name}`, { entry: extensionEntry.name });
|
|
349
|
+
}
|
|
350
|
+
const id = validateExtensionName(extensionEntry.name);
|
|
351
|
+
const extensionRoot = path.join(extensionsRoot, id);
|
|
352
|
+
const versionEntries = fs.readdirSync(extensionRoot, { withFileTypes: true }).filter((entry) => !entry.name.startsWith("."));
|
|
353
|
+
if (versionEntries.length === 0) throw extensionError("EXTENSION_REPOSITORY_INVALID", `Extension ${id} contains no versions`, { extension: id });
|
|
354
|
+
const versions = [];
|
|
355
|
+
for (const versionEntry of versionEntries) {
|
|
356
|
+
if (!versionEntry.isDirectory() || versionEntry.isSymbolicLink()) {
|
|
357
|
+
throw extensionError("EXTENSION_REPOSITORY_INVALID", `Extension version entry must be a directory: ${id}/${versionEntry.name}`, { extension: id, version: versionEntry.name });
|
|
358
|
+
}
|
|
359
|
+
const version = parseSemver(versionEntry.name).raw;
|
|
360
|
+
const sourceRoot = path.join(extensionRoot, version);
|
|
361
|
+
const manifestFile = path.join(sourceRoot, "manifest.json");
|
|
362
|
+
assertRegularFile(manifestFile, "EXTENSION_MANIFEST_MISSING", "extension manifest");
|
|
363
|
+
const manifestBytes = fs.readFileSync(manifestFile);
|
|
364
|
+
const rawManifest = readJson(manifestFile, "EXTENSION_MANIFEST_PARSE_FAILED");
|
|
365
|
+
const envelope = validateManifestEnvelope(rawManifest, { expectedId: id, expectedVersion: version });
|
|
366
|
+
if (!supportsExtensionSpec(envelope.extensionSpecVersion, supportedExtensionSpecVersions)) {
|
|
367
|
+
versions.push(Object.freeze({
|
|
368
|
+
id,
|
|
369
|
+
version,
|
|
370
|
+
sourceRoot,
|
|
371
|
+
manifestFile,
|
|
372
|
+
manifest: envelope,
|
|
373
|
+
manifestSha256: sha256(manifestBytes),
|
|
374
|
+
extensionSpecVersion: envelope.extensionSpecVersion,
|
|
375
|
+
supported: false,
|
|
376
|
+
}));
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const manifest = validateManifest(rawManifest, {
|
|
380
|
+
expectedId: id,
|
|
381
|
+
expectedVersion: version,
|
|
382
|
+
supportedExtensionSpecVersions,
|
|
383
|
+
...(options.protectedTargets ? { protectedTargets: options.protectedTargets } : {}),
|
|
384
|
+
});
|
|
385
|
+
const entryFile = path.join(sourceRoot, ...manifest.entry.split("/"));
|
|
386
|
+
assertRegularFile(entryFile, "EXTENSION_ENTRY_MISSING", "extension entry");
|
|
387
|
+
const entrySha256 = sha256(fs.readFileSync(entryFile));
|
|
388
|
+
if (entrySha256 !== manifest.entrySha256) {
|
|
389
|
+
throw extensionError("EXTENSION_ENTRY_HASH_MISMATCH", `Extension entry hash mismatch: ${id}@${version}`, { extension: id, version, expectedSha256: manifest.entrySha256, actualSha256: entrySha256 });
|
|
390
|
+
}
|
|
391
|
+
const packageSha256 = directoryDigest(sourceRoot);
|
|
392
|
+
versions.push(Object.freeze({
|
|
393
|
+
id,
|
|
394
|
+
version,
|
|
395
|
+
sourceRoot,
|
|
396
|
+
manifestFile,
|
|
397
|
+
entryFile,
|
|
398
|
+
manifest,
|
|
399
|
+
manifestSha256: sha256(manifestBytes),
|
|
400
|
+
entrySha256,
|
|
401
|
+
packageSha256,
|
|
402
|
+
extensionSpecVersion: manifest.extensionSpecVersion,
|
|
403
|
+
supported: true,
|
|
404
|
+
}));
|
|
405
|
+
}
|
|
406
|
+
versions.sort((left, right) => compareSemver(right.version, left.version));
|
|
407
|
+
const latestSupported = versions.find((entry) => entry.supported) || null;
|
|
408
|
+
return Object.freeze({
|
|
409
|
+
id,
|
|
410
|
+
name: (latestSupported || versions[0]).manifest.name,
|
|
411
|
+
versions: Object.freeze(versions),
|
|
412
|
+
latestSupported,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function discoverExtensions(options = {}) {
|
|
417
|
+
const extensionsRoot = path.resolve(options.extensionsRoot || EXTENSIONS_ROOT);
|
|
418
|
+
const supportedExtensionSpecVersions = Object.freeze([...(options.supportedExtensionSpecVersions || SUPPORTED_EXTENSION_SPEC_VERSIONS)]);
|
|
419
|
+
if (!fs.existsSync(extensionsRoot)) return options.tolerant ? { catalog: [], invalid: [] } : [];
|
|
420
|
+
const entries = fs.readdirSync(extensionsRoot, { withFileTypes: true }).filter((entry) => !entry.name.startsWith("."));
|
|
421
|
+
const catalog = [];
|
|
422
|
+
const invalid = [];
|
|
423
|
+
for (const extensionEntry of entries) {
|
|
424
|
+
try {
|
|
425
|
+
catalog.push(discoverExtensionEntry(extensionsRoot, extensionEntry, supportedExtensionSpecVersions, options));
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (!options.tolerant) throw error;
|
|
428
|
+
invalid.push({ id: EXTENSION_NAME_PATTERN.test(extensionEntry.name) ? extensionEntry.name : null, entry: extensionEntry.name, code: error.code || "EXTENSION_REPOSITORY_INVALID", message: error.message });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catalog.sort((left, right) => left.id.localeCompare(right.id));
|
|
432
|
+
invalid.sort((left, right) => left.entry.localeCompare(right.entry));
|
|
433
|
+
return options.tolerant ? { catalog, invalid } : catalog;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function extensionStatePath(root) {
|
|
437
|
+
return path.join(path.resolve(root), LOCAL_DIRECTORY, EXTENSION_STATE_FILE);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function emptyExtensionState() {
|
|
441
|
+
return { schemaVersion: 1, experimental: true, extensions: {} };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function validateInstalledState(id, installed) {
|
|
445
|
+
if (installed === null) return null;
|
|
446
|
+
if (!installed || typeof installed !== "object" || Array.isArray(installed)) throw extensionError("EXTENSION_STATE_INVALID", `Invalid installed state for ${id}`, { extension: id });
|
|
447
|
+
parseSemver(installed.version);
|
|
448
|
+
if (!SHA256_PATTERN.test(installed.manifestSha256 || "") || !Array.isArray(installed.artifacts)) {
|
|
449
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Invalid installed state for ${id}`, { extension: id });
|
|
450
|
+
}
|
|
451
|
+
const protocolVersion = installed.protocolVersion || 1;
|
|
452
|
+
if (![1, 2, 3].includes(protocolVersion) || (protocolVersion >= 2 && !SHA256_PATTERN.test(installed.packageSha256 || ""))) {
|
|
453
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Invalid installed protocol state for ${id}`, { extension: id, protocolVersion });
|
|
454
|
+
}
|
|
455
|
+
if (protocolVersion === 3 && (!Number.isInteger(installed.extensionSpecVersion) || installed.extensionSpecVersion < 1)) {
|
|
456
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Installed protocol v3 state for ${id} requires extensionSpecVersion`, { extension: id, extensionSpecVersion: installed.extensionSpecVersion ?? null });
|
|
457
|
+
}
|
|
458
|
+
const ids = new Set();
|
|
459
|
+
const declaredArtifacts = [];
|
|
460
|
+
const exclusiveProtectedTargets = coreManagedTargets();
|
|
461
|
+
const sharedProtectedTargets = coreWholeFileTargets();
|
|
462
|
+
for (const artifact of installed.artifacts) {
|
|
463
|
+
const artifactId = validateExtensionName(artifact?.id, "artifact");
|
|
464
|
+
if (ids.has(artifactId)) throw extensionError("EXTENSION_STATE_INVALID", `Duplicate installed artifact id for ${id}: ${artifactId}`, { extension: id, artifact: artifactId });
|
|
465
|
+
ids.add(artifactId);
|
|
466
|
+
if (!SHA256_PATTERN.test(artifact.installedSha256 || "")) throw extensionError("EXTENSION_STATE_INVALID", `Invalid installed artifact state for ${id}`, { extension: id });
|
|
467
|
+
const kind = artifact.kind || "file";
|
|
468
|
+
if (!ARTIFACT_KINDS.has(kind) && !LEGACY_ARTIFACT_KINDS.has(kind)) throw extensionError("EXTENSION_STATE_INVALID", `Invalid installed artifact kind for ${id}: ${kind}`, { extension: id, kind });
|
|
469
|
+
const target = normalizeArtifactTarget(artifact.target);
|
|
470
|
+
if (target !== targetForArtifact({ ...artifact, kind })) throw extensionError("EXTENSION_STATE_INVALID", `Installed extension ${id} has an invalid target for ${kind}`, { extension: id, target, kind });
|
|
471
|
+
const protectedTargets = ["file", "directory"].includes(kind) ? exclusiveProtectedTargets : sharedProtectedTargets;
|
|
472
|
+
if (ARTIFACT_KINDS.has(kind) && targetIsProtected(target, protectedTargets, ["file", "directory"].includes(kind))) throw extensionError("EXTENSION_STATE_INVALID", `Installed extension ${id} claims core target ${target}`, { extension: id, target });
|
|
473
|
+
if (protocolVersion === 1 && !["file", ...LEGACY_ARTIFACT_KINDS].includes(kind)) {
|
|
474
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Installed protocol v1 state for ${id} cannot contain ${kind}`, { extension: id, artifact: artifactId, kind });
|
|
475
|
+
}
|
|
476
|
+
if (protocolVersion >= 2) {
|
|
477
|
+
const expectedOwnership = ["file", "directory"].includes(kind) ? "exclusive" : "shared";
|
|
478
|
+
if (artifact.ownership !== expectedOwnership) throw extensionError("EXTENSION_STATE_INVALID", `Invalid installed ownership for ${id}/${artifactId}`, { extension: id, artifact: artifactId, kind, ownership: artifact.ownership });
|
|
479
|
+
} else if (kind === "file" && artifact.ownership !== undefined && artifact.ownership !== "exclusive") {
|
|
480
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Invalid legacy file ownership for ${id}/${artifactId}`, { extension: id, artifact: artifactId, ownership: artifact.ownership });
|
|
481
|
+
}
|
|
482
|
+
if (kind === "text-block" && !["text", "toml"].includes(artifact.format || "text")) {
|
|
483
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Invalid installed text block for ${id}/${artifactId}`, { extension: id, artifact: artifactId });
|
|
484
|
+
}
|
|
485
|
+
if (kind === "json-member" && (typeof artifact.selector !== "string" || !Object.prototype.hasOwnProperty.call(artifact, "payload"))) {
|
|
486
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Invalid installed JSON member for ${id}/${artifactId}`, { extension: id, artifact: artifactId });
|
|
487
|
+
}
|
|
488
|
+
if (kind === "codex-hooks" && (!artifact.payload || artifact.payload.schemaVersion !== 1 || !artifact.payload.hooks)) {
|
|
489
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Installed Hook artifact for ${id} is missing its payload`, { extension: id, artifact: artifact.id });
|
|
490
|
+
}
|
|
491
|
+
const normalized = { ...artifact, id: artifactId, kind, target };
|
|
492
|
+
if (declaredArtifacts.some((existing) => artifactsConflict(existing, normalized))) {
|
|
493
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Installed extension ${id} has overlapping artifact ownership at ${target}`, { extension: id, artifact: artifactId, target });
|
|
494
|
+
}
|
|
495
|
+
declaredArtifacts.push(normalized);
|
|
496
|
+
}
|
|
497
|
+
return installed;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function loadExtensionState(root) {
|
|
501
|
+
const file = extensionStatePath(root);
|
|
502
|
+
if (!fs.existsSync(file)) return emptyExtensionState();
|
|
503
|
+
const state = readJson(file, "EXTENSION_STATE_PARSE_FAILED");
|
|
504
|
+
if (state?.schemaVersion !== 1 || state.experimental !== true || !state.extensions || typeof state.extensions !== "object" || Array.isArray(state.extensions)) {
|
|
505
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Invalid Workspace extension state: ${file}`, { file });
|
|
506
|
+
}
|
|
507
|
+
const ownedArtifacts = [];
|
|
508
|
+
for (const [id, value] of Object.entries(state.extensions)) {
|
|
509
|
+
validateExtensionName(id);
|
|
510
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw extensionError("EXTENSION_STATE_INVALID", `Invalid extension state for ${id}`, { extension: id });
|
|
511
|
+
const installed = validateInstalledState(id, value.installed ?? null);
|
|
512
|
+
if (value.lastAttempt !== undefined) {
|
|
513
|
+
const attempt = value.lastAttempt;
|
|
514
|
+
if (!attempt || typeof attempt !== "object" || !["installed", "failed"].includes(attempt.status) || !attempt.version) {
|
|
515
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Invalid lastAttempt state for ${id}`, { extension: id });
|
|
516
|
+
}
|
|
517
|
+
parseSemver(attempt.version);
|
|
518
|
+
if (attempt.extensionSpecVersion !== undefined && (!Number.isInteger(attempt.extensionSpecVersion) || attempt.extensionSpecVersion < 1)) {
|
|
519
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Invalid lastAttempt Extension Spec for ${id}`, { extension: id, extensionSpecVersion: attempt.extensionSpecVersion });
|
|
520
|
+
}
|
|
521
|
+
if (attempt.status === "failed" && (!attempt.code || !attempt.message)) throw extensionError("EXTENSION_STATE_INVALID", `Failed lastAttempt for ${id} requires code and message`, { extension: id });
|
|
522
|
+
}
|
|
523
|
+
for (const artifact of installed?.artifacts || []) {
|
|
524
|
+
const conflicting = ownedArtifacts.find((entry) => artifactsConflict(entry.artifact, artifact));
|
|
525
|
+
if (conflicting) {
|
|
526
|
+
throw extensionError("EXTENSION_STATE_INVALID", `Installed extensions ${conflicting.id} and ${id} have overlapping ownership at ${artifact.target}`, {
|
|
527
|
+
extension: id,
|
|
528
|
+
conflictingExtension: conflicting.id,
|
|
529
|
+
target: artifact.target,
|
|
530
|
+
...(artifact.selector ? { selector: artifact.selector } : {}),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
ownedArtifacts.push({ id, artifact });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return state;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function inspectExtensionState(root) {
|
|
540
|
+
try {
|
|
541
|
+
return { state: loadExtensionState(root), error: null };
|
|
542
|
+
} catch (error) {
|
|
543
|
+
return { state: emptyExtensionState(), error };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function saveExtensionState(root, state, options = {}) {
|
|
548
|
+
(options.atomicWrite || atomicWrite)(extensionStatePath(root), `${JSON.stringify(state, null, 2)}\n`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function parseExtensionSelection(value) {
|
|
552
|
+
if (value === undefined) return null;
|
|
553
|
+
const text = String(value).trim();
|
|
554
|
+
if (!text) throw extensionError("EXTENSION_SELECTION_INVALID", "--extensions requires one or more extension names or none");
|
|
555
|
+
if (text === "none") return [];
|
|
556
|
+
const names = text.split(",").map((entry) => entry.trim());
|
|
557
|
+
if (names.some((entry) => !entry) || names.includes("none")) {
|
|
558
|
+
throw extensionError("EXTENSION_SELECTION_INVALID", `Invalid extension selection: ${text}`, { selection: text });
|
|
559
|
+
}
|
|
560
|
+
return normalizeExtensionNames(names);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function normalizeExtensionNames(values) {
|
|
564
|
+
if (!Array.isArray(values)) throw extensionError("EXTENSION_SELECTION_INVALID", "Extension selection must be a list of names");
|
|
565
|
+
const names = values.map((entry) => String(entry || "").trim());
|
|
566
|
+
if (names.some((entry) => !entry) || new Set(names).size !== names.length) {
|
|
567
|
+
throw extensionError("EXTENSION_SELECTION_INVALID", `Invalid extension selection: ${names.join(",")}`, { selection: names });
|
|
568
|
+
}
|
|
569
|
+
for (const name of names) {
|
|
570
|
+
if (name.includes("@")) throw extensionError("EXTENSION_VERSION_SELECTION_UNSUPPORTED", `Extension versions cannot be selected explicitly: ${name}`, { extension: name });
|
|
571
|
+
validateExtensionName(name);
|
|
572
|
+
}
|
|
573
|
+
return names;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function applicableArtifacts(manifest, tools = []) {
|
|
577
|
+
const selected = new Set(tools);
|
|
578
|
+
return manifest.outputs.filter((artifact) => !artifact.tools || artifact.tools.some((tool) => selected.has(tool)));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function installedExtensionNames(state) {
|
|
582
|
+
return Object.entries(state.extensions).filter(([, value]) => value.installed).map(([id]) => id);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function hasWorkspaceConfiguration(root) {
|
|
586
|
+
return fs.existsSync(configPath(path.resolve(root)));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function resolveExtensionPlans(catalog, requested, options = {}) {
|
|
590
|
+
const tools = options.tools || [];
|
|
591
|
+
const state = options.state || emptyExtensionState();
|
|
592
|
+
const byId = new Map(catalog.map((entry) => [entry.id, entry]));
|
|
593
|
+
const plans = [];
|
|
594
|
+
const ownedArtifacts = [];
|
|
595
|
+
for (const [id, value] of Object.entries(state.extensions)) {
|
|
596
|
+
if (!value.installed || requested.includes(id)) continue;
|
|
597
|
+
for (const artifact of value.installed.artifacts) ownedArtifacts.push({ id, artifact });
|
|
598
|
+
}
|
|
599
|
+
for (const id of requested) {
|
|
600
|
+
const extension = byId.get(id);
|
|
601
|
+
if (!extension) throw extensionError("EXTENSION_NOT_FOUND", `Unknown built-in extension: ${id}`, { extension: id });
|
|
602
|
+
if (!extension.latestSupported) {
|
|
603
|
+
throw extensionError("EXTENSION_SPEC_UNSUPPORTED", `No version of ${id} implements an Extension Spec supported by this Host`, {
|
|
604
|
+
extension: id,
|
|
605
|
+
supportedExtensionSpecVersions: [...SUPPORTED_EXTENSION_SPEC_VERSIONS],
|
|
606
|
+
availableExtensionSpecVersions: [...new Set(extension.versions.map((entry) => entry.extensionSpecVersion))].sort((left, right) => left - right),
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const resolved = extension.latestSupported;
|
|
610
|
+
const artifacts = applicableArtifacts(resolved.manifest, tools);
|
|
611
|
+
if (artifacts.length === 0) throw extensionError("EXTENSION_NO_APPLICABLE_OUTPUTS", `Extension ${id} has no outputs for the selected tools`, { extension: id, tools });
|
|
612
|
+
for (const artifact of artifacts) {
|
|
613
|
+
const conflicting = ownedArtifacts.find((entry) => artifactsConflict(entry.artifact, artifact));
|
|
614
|
+
if (conflicting) {
|
|
615
|
+
throw extensionError("EXTENSION_TARGET_CONFLICT", `Extensions ${conflicting.id} and ${id} have overlapping ownership at ${artifact.target}`, {
|
|
616
|
+
extension: id,
|
|
617
|
+
conflictingExtension: conflicting.id,
|
|
618
|
+
target: artifact.target,
|
|
619
|
+
...(artifact.selector ? { selector: artifact.selector } : {}),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
ownedArtifacts.push({ id, artifact });
|
|
623
|
+
}
|
|
624
|
+
plans.push(Object.freeze({
|
|
625
|
+
id,
|
|
626
|
+
name: resolved.manifest.name,
|
|
627
|
+
version: resolved.version,
|
|
628
|
+
extensionSpecVersion: resolved.extensionSpecVersion,
|
|
629
|
+
sourceRoot: resolved.sourceRoot,
|
|
630
|
+
entryFile: resolved.entryFile,
|
|
631
|
+
manifestFile: resolved.manifestFile,
|
|
632
|
+
manifest: resolved.manifest,
|
|
633
|
+
manifestSha256: resolved.manifestSha256,
|
|
634
|
+
entrySha256: resolved.entrySha256,
|
|
635
|
+
packageSha256: resolved.packageSha256,
|
|
636
|
+
capabilities: resolved.manifest.capabilities,
|
|
637
|
+
artifacts: Object.freeze(artifacts.slice()),
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
return Object.freeze(plans);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function prepareExtensionPlans(catalogResult, requested, options = {}) {
|
|
644
|
+
const catalog = Array.isArray(catalogResult) ? catalogResult : catalogResult.catalog;
|
|
645
|
+
const invalid = Array.isArray(catalogResult) ? [] : catalogResult.invalid;
|
|
646
|
+
const failures = [];
|
|
647
|
+
const diagnostics = invalid.map((entry) => ({ code: entry.code, severity: "warning", message: entry.message, extension: entry.id, entry: entry.entry }));
|
|
648
|
+
const validIds = new Set(catalog.map((entry) => entry.id));
|
|
649
|
+
const invalidById = new Map(invalid.filter((entry) => entry.id).map((entry) => [entry.id, entry]));
|
|
650
|
+
for (const id of requested) {
|
|
651
|
+
if (!validIds.has(id) && !invalidById.has(id)) throw extensionError("EXTENSION_NOT_FOUND", "Unknown built-in extension: " + id, { extension: id });
|
|
652
|
+
}
|
|
653
|
+
if (options.stateError) {
|
|
654
|
+
for (const id of requested) failures.push({ id, version: null, status: "failed", code: options.stateError.code || "EXTENSION_STATE_INVALID", message: options.stateError.message, statePersisted: false, phase: "prepare" });
|
|
655
|
+
diagnostics.push({ code: options.stateError.code || "EXTENSION_STATE_INVALID", severity: "warning", message: options.stateError.message });
|
|
656
|
+
return { plans: [], failures, diagnostics };
|
|
657
|
+
}
|
|
658
|
+
const validRequested = [];
|
|
659
|
+
for (const id of requested) {
|
|
660
|
+
const invalidEntry = invalidById.get(id);
|
|
661
|
+
if (invalidEntry) failures.push({ id, version: null, status: "failed", code: invalidEntry.code, message: invalidEntry.message, statePersisted: false, phase: "prepare" });
|
|
662
|
+
else validRequested.push(id);
|
|
663
|
+
}
|
|
664
|
+
const plans = [];
|
|
665
|
+
const planningState = structuredClone(options.state || emptyExtensionState());
|
|
666
|
+
for (const id of validRequested) {
|
|
667
|
+
try {
|
|
668
|
+
const plan = resolveExtensionPlans(catalog, [id], { ...options, state: planningState })[0];
|
|
669
|
+
plans.push(plan);
|
|
670
|
+
planningState.extensions[id] = {
|
|
671
|
+
installed: {
|
|
672
|
+
artifacts: plan.artifacts.map((artifact) => ({ id: artifact.id, kind: artifact.kind, ownership: artifact.ownership, target: artifact.target, ...(artifact.selector ? { selector: artifact.selector } : {}) })),
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
} catch (error) {
|
|
676
|
+
failures.push({ id, version: null, status: "failed", code: error.code || "EXTENSION_INIT_FAILED", message: error.message, statePersisted: false, phase: "prepare" });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return { plans, failures, diagnostics };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function assertSafeWorkspaceTarget(root, target, options = {}) {
|
|
683
|
+
normalizeArtifactTarget(target);
|
|
684
|
+
const resolvedRoot = path.resolve(root);
|
|
685
|
+
const resolved = path.resolve(resolvedRoot, ...target.split("/"));
|
|
686
|
+
if (resolved === resolvedRoot || !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
687
|
+
throw extensionError("EXTENSION_ARTIFACT_TARGET_INVALID", `Extension target escapes Workspace: ${target}`, { target });
|
|
688
|
+
}
|
|
689
|
+
let current = resolvedRoot;
|
|
690
|
+
for (const segment of target.split("/")) {
|
|
691
|
+
current = path.join(current, segment);
|
|
692
|
+
if (!fs.existsSync(current)) continue;
|
|
693
|
+
const stat = fs.lstatSync(current);
|
|
694
|
+
if (stat.isSymbolicLink()) throw extensionError("EXTENSION_ARTIFACT_SYMLINK", `Extension target traverses a symbolic link: ${target}`, { target, path: current });
|
|
695
|
+
if (current !== resolved && !stat.isDirectory()) throw extensionError("EXTENSION_ARTIFACT_TARGET_INVALID", `Extension target parent is not a directory: ${target}`, { target, path: current });
|
|
696
|
+
if (current === resolved && !(options.directory ? stat.isDirectory() : stat.isFile())) throw extensionError("EXTENSION_ARTIFACT_TARGET_INVALID", `Extension target exists and is not a ${options.directory ? "directory" : "regular file"}: ${target}`, { target, path: current });
|
|
697
|
+
}
|
|
698
|
+
return resolved;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function listOutputEntries(root) {
|
|
702
|
+
const entries = [];
|
|
703
|
+
const visit = (directory) => {
|
|
704
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
705
|
+
const file = path.join(directory, entry.name);
|
|
706
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
707
|
+
const stat = fs.lstatSync(file);
|
|
708
|
+
if (stat.isSymbolicLink()) throw extensionError("EXTENSION_OUTPUT_SYMLINK", `Extension output contains a symbolic link: ${relative}`, { target: relative });
|
|
709
|
+
if (stat.isDirectory()) {
|
|
710
|
+
entries.push({ relative, kind: "directory" });
|
|
711
|
+
visit(file);
|
|
712
|
+
} else if (stat.isFile()) entries.push({ relative, kind: "file" });
|
|
713
|
+
else throw extensionError("EXTENSION_OUTPUT_INVALID", `Extension output is not a regular file: ${relative}`, { target: relative });
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
visit(root);
|
|
717
|
+
return entries.sort((left, right) => left.relative.localeCompare(right.relative));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function validateInitResult(value, plan) {
|
|
721
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw extensionError("EXTENSION_RESULT_INVALID", `Extension ${plan.id} result must be a JSON object`, { extension: plan.id });
|
|
722
|
+
assertOnlyKeys(value, new Set(["schemaVersion", "extensionSpecVersion", "extension", "outputs"]), "EXTENSION_RESULT_INVALID", `Extension ${plan.id} result`);
|
|
723
|
+
if (value.schemaVersion !== 1 || value.extensionSpecVersion !== plan.extensionSpecVersion || !value.extension || typeof value.extension !== "object" || Array.isArray(value.extension)) {
|
|
724
|
+
throw extensionError("EXTENSION_RESULT_INVALID", `Extension ${plan.id} result must use schemaVersion 1`, { extension: plan.id });
|
|
725
|
+
}
|
|
726
|
+
assertOnlyKeys(value.extension, new Set(["id", "version"]), "EXTENSION_RESULT_INVALID", `Extension ${plan.id} result identity`);
|
|
727
|
+
if (value.extension.id !== plan.id || value.extension.version !== plan.version) {
|
|
728
|
+
throw extensionError("EXTENSION_RESULT_IDENTITY_MISMATCH", `Extension result identity does not match ${plan.id}@${plan.version}`, { extension: plan.id, version: plan.version, actual: value.extension });
|
|
729
|
+
}
|
|
730
|
+
if (!Array.isArray(value.outputs) || value.outputs.length !== plan.artifacts.length) {
|
|
731
|
+
throw extensionError("EXTENSION_ARTIFACT_MISSING", `Extension ${plan.id} result must contain every applicable output`, { extension: plan.id, expected: plan.artifacts.map((artifact) => artifact.id), actualCount: Array.isArray(value.outputs) ? value.outputs.length : null });
|
|
732
|
+
}
|
|
733
|
+
const declared = new Map(plan.artifacts.map((artifact) => [artifact.id, artifact]));
|
|
734
|
+
const seenIds = new Set();
|
|
735
|
+
const seenSources = [];
|
|
736
|
+
const outputs = value.outputs.map((output) => {
|
|
737
|
+
if (!output || typeof output !== "object" || Array.isArray(output)) throw extensionError("EXTENSION_RESULT_INVALID", `Extension ${plan.id} contains an invalid result output`, { extension: plan.id });
|
|
738
|
+
assertOnlyKeys(output, new Set(["id", "source"]), "EXTENSION_RESULT_INVALID", `Extension ${plan.id} result output`);
|
|
739
|
+
const id = validateExtensionName(output.id, "output");
|
|
740
|
+
if (!declared.has(id)) throw extensionError("EXTENSION_ARTIFACT_UNDECLARED", `Extension ${plan.id} returned undeclared output ${id}`, { extension: plan.id, output: id });
|
|
741
|
+
if (seenIds.has(id)) throw extensionError("EXTENSION_ARTIFACT_DUPLICATE", `Extension ${plan.id} returned output ${id} more than once`, { extension: plan.id, output: id });
|
|
742
|
+
const source = normalizeArtifactTarget(output.source);
|
|
743
|
+
if (seenSources.some((existing) => existing === source || existing.startsWith(`${source}/`) || source.startsWith(`${existing}/`))) {
|
|
744
|
+
throw extensionError("EXTENSION_ARTIFACT_DUPLICATE", `Extension ${plan.id} returned overlapping output source ${source}`, { extension: plan.id, source });
|
|
745
|
+
}
|
|
746
|
+
seenIds.add(id);
|
|
747
|
+
seenSources.push(source);
|
|
748
|
+
return Object.freeze({ id, source });
|
|
749
|
+
});
|
|
750
|
+
for (const id of declared.keys()) if (!seenIds.has(id)) throw extensionError("EXTENSION_ARTIFACT_MISSING", `Extension ${plan.id} did not return output ${id}`, { extension: plan.id, output: id });
|
|
751
|
+
return Object.freeze(outputs);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function validateInitContext(value, plan) {
|
|
755
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw extensionError("EXTENSION_CONTEXT_INVALID", `Extension ${plan.id} context must be an object`, { extension: plan.id });
|
|
756
|
+
assertOnlyKeys(value, new Set(["schemaVersion", "extensionSpecVersion", "extension", "workspace", "tools"]), "EXTENSION_CONTEXT_INVALID", `Extension ${plan.id} context`);
|
|
757
|
+
if (value.schemaVersion !== 1 || value.extensionSpecVersion !== plan.extensionSpecVersion || !value.extension || typeof value.extension !== "object" || Array.isArray(value.extension)) throw extensionError("EXTENSION_CONTEXT_INVALID", `Extension ${plan.id} context must use schemaVersion 1 and Extension Spec ${plan.extensionSpecVersion}`, { extension: plan.id, extensionSpecVersion: plan.extensionSpecVersion });
|
|
758
|
+
assertOnlyKeys(value.extension, new Set(["id", "version"]), "EXTENSION_CONTEXT_INVALID", `Extension ${plan.id} context identity`);
|
|
759
|
+
if (value.extension.id !== plan.id || value.extension.version !== plan.version) throw extensionError("EXTENSION_CONTEXT_INVALID", `Extension context identity does not match ${plan.id}@${plan.version}`, { extension: plan.id, version: plan.version });
|
|
760
|
+
const workspace = value.workspace;
|
|
761
|
+
if (!workspace || typeof workspace !== "object" || Array.isArray(workspace)) throw extensionError("EXTENSION_CONTEXT_INVALID", `Extension ${plan.id} context requires workspace metadata`, { extension: plan.id });
|
|
762
|
+
assertOnlyKeys(workspace, new Set(["name", "uuid", "language"]), "EXTENSION_CONTEXT_INVALID", `Extension ${plan.id} workspace context`);
|
|
763
|
+
for (const field of ["name", "uuid", "language"]) if (typeof workspace[field] !== "string" || !workspace[field]) throw extensionError("EXTENSION_CONTEXT_INVALID", `Extension ${plan.id} workspace ${field} is invalid`, { extension: plan.id, field });
|
|
764
|
+
if (!Array.isArray(value.tools) || new Set(value.tools).size !== value.tools.length || value.tools.some((tool) => !SUPPORTED_TOOLS.has(tool))) throw extensionError("EXTENSION_CONTEXT_INVALID", `Extension ${plan.id} context tools are invalid`, { extension: plan.id });
|
|
765
|
+
return Object.freeze({
|
|
766
|
+
schemaVersion: 1,
|
|
767
|
+
extensionSpecVersion: plan.extensionSpecVersion,
|
|
768
|
+
extension: Object.freeze({ id: plan.id, version: plan.version }),
|
|
769
|
+
workspace: Object.freeze({ name: workspace.name, uuid: workspace.uuid, language: workspace.language }),
|
|
770
|
+
tools: Object.freeze(value.tools.slice()),
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function verifyExtensionOutput(outputRoot, result, artifactsOrPlan) {
|
|
775
|
+
const plan = Array.isArray(artifactsOrPlan)
|
|
776
|
+
? { id: result?.extension?.id || "extension", version: result?.extension?.version || "0.0.0", extensionSpecVersion: result?.extensionSpecVersion || 1, artifacts: artifactsOrPlan }
|
|
777
|
+
: artifactsOrPlan;
|
|
778
|
+
const outputs = validateInitResult(result, plan);
|
|
779
|
+
const declared = new Map(plan.artifacts.map((artifact) => [artifact.id, artifact]));
|
|
780
|
+
const actual = listOutputEntries(outputRoot);
|
|
781
|
+
for (const entry of actual) {
|
|
782
|
+
const covered = outputs.some(({ id, source }) => {
|
|
783
|
+
const artifact = declared.get(id);
|
|
784
|
+
return entry.relative === source || source.startsWith(`${entry.relative}/`) || (artifact.kind === "directory" && entry.relative.startsWith(`${source}/`));
|
|
785
|
+
});
|
|
786
|
+
if (!covered) throw extensionError("EXTENSION_ARTIFACT_UNDECLARED", `Extension generated undeclared output: ${entry.relative}`, { target: entry.relative });
|
|
787
|
+
}
|
|
788
|
+
const verified = [];
|
|
789
|
+
for (const { id, source } of outputs) {
|
|
790
|
+
const artifact = declared.get(id);
|
|
791
|
+
const candidate = path.resolve(outputRoot, ...source.split("/"));
|
|
792
|
+
if (!candidate.startsWith(`${path.resolve(outputRoot)}${path.sep}`) || !fs.existsSync(candidate)) {
|
|
793
|
+
throw extensionError("EXTENSION_ARTIFACT_MISSING", `Extension did not generate output ${id}: ${source}`, { output: id, source });
|
|
794
|
+
}
|
|
795
|
+
const stat = fs.lstatSync(candidate);
|
|
796
|
+
if (stat.isSymbolicLink()) throw extensionError("EXTENSION_OUTPUT_SYMLINK", `Extension output contains a symbolic link: ${source}`, { target: source });
|
|
797
|
+
if (artifact.kind === "directory") {
|
|
798
|
+
if (!stat.isDirectory()) throw extensionError("EXTENSION_OUTPUT_INVALID", `Extension output ${id} must be a directory`, { output: id, source });
|
|
799
|
+
verified.push({ ...artifact, source, directory: candidate, installedSha256: directoryDigest(candidate) });
|
|
800
|
+
} else {
|
|
801
|
+
if (!stat.isFile()) throw extensionError("EXTENSION_OUTPUT_INVALID", `Extension output ${id} must be a regular file`, { output: id, source });
|
|
802
|
+
const content = fs.readFileSync(candidate);
|
|
803
|
+
verified.push({ ...artifact, source, file: candidate, content, installedSha256: sha256(content) });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return verified;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function runExtensionProcess(plan, contextFile, outputRoot, resultFile, options = {}) {
|
|
810
|
+
const runner = options.spawnSync || spawnSync;
|
|
811
|
+
const environment = {};
|
|
812
|
+
for (const name of ["PATH", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "SystemRoot", "WINDIR", "PATHEXT"]) {
|
|
813
|
+
if (process.env[name] !== undefined) environment[name] = process.env[name];
|
|
814
|
+
}
|
|
815
|
+
const result = runner(process.execPath, [plan.entryFile, "--context", contextFile, "--output", outputRoot, "--result", resultFile], {
|
|
816
|
+
cwd: plan.sourceRoot,
|
|
817
|
+
env: environment,
|
|
818
|
+
encoding: "utf8",
|
|
819
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
820
|
+
timeout: plan.manifest.timeoutMs,
|
|
821
|
+
killSignal: "SIGKILL",
|
|
822
|
+
maxBuffer: 1024 * 1024,
|
|
823
|
+
});
|
|
824
|
+
if (result.error?.code === "ETIMEDOUT" || result.signal === "SIGKILL") {
|
|
825
|
+
throw extensionError("EXTENSION_INIT_TIMEOUT", `Extension ${plan.id} timed out after ${plan.manifest.timeoutMs}ms`, { extension: plan.id, timeoutMs: plan.manifest.timeoutMs });
|
|
826
|
+
}
|
|
827
|
+
if (result.error) throw extensionError("EXTENSION_INIT_FAILED", `Extension ${plan.id} failed to start: ${result.error.message}`, { extension: plan.id });
|
|
828
|
+
if (result.status !== 0) {
|
|
829
|
+
const detail = String(result.stderr || result.stdout || "").trim().split(/\r?\n/)[0];
|
|
830
|
+
throw extensionError("EXTENSION_INIT_FAILED", `Extension ${plan.id} exited with status ${result.status}${detail ? `: ${detail}` : ""}`, { extension: plan.id, exitCode: result.status });
|
|
831
|
+
}
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function stateFingerprint(root) {
|
|
836
|
+
const file = extensionStatePath(root);
|
|
837
|
+
return fs.existsSync(file) ? sha256(fs.readFileSync(file)) : null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function installedIsCurrent(root, plan, installed, state) {
|
|
841
|
+
if (!installed || installed.protocolVersion !== 3 || installed.extensionSpecVersion !== plan.extensionSpecVersion || installed.version !== plan.version || installed.manifestSha256 !== plan.manifestSha256 || installed.packageSha256 !== plan.packageSha256) return false;
|
|
842
|
+
if (installed.artifacts.length !== plan.artifacts.length) return false;
|
|
843
|
+
const byId = new Map(installed.artifacts.map((artifact) => [artifact.id, artifact]));
|
|
844
|
+
return plan.artifacts.every((artifact) => {
|
|
845
|
+
const stateArtifact = byId.get(artifact.id);
|
|
846
|
+
return stateArtifact && stateArtifact.kind === artifact.kind && stateArtifact.ownership === artifact.ownership && stateArtifact.target === artifact.target && (artifact.selector || null) === (stateArtifact.selector || null) && (artifact.format || null) === (stateArtifact.format || null);
|
|
847
|
+
}) && installedArtifactsCurrent(root, plan.id, installed, state);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function assertInstallOwnership(root, plan, state) {
|
|
851
|
+
const installed = state.extensions[plan.id]?.installed || null;
|
|
852
|
+
const ownArtifacts = new Map((installed?.artifacts || []).filter((artifact) => ["file", "directory"].includes(artifact.kind || "file")).map((artifact) => [artifact.target, artifact]));
|
|
853
|
+
const otherArtifacts = [];
|
|
854
|
+
for (const [id, value] of Object.entries(state.extensions)) {
|
|
855
|
+
if (id === plan.id || !value.installed) continue;
|
|
856
|
+
for (const artifact of value.installed.artifacts) otherArtifacts.push({ id, artifact });
|
|
857
|
+
}
|
|
858
|
+
for (const artifact of plan.artifacts) {
|
|
859
|
+
const conflicting = otherArtifacts.find((entry) => artifactsConflict(entry.artifact, artifact));
|
|
860
|
+
if (conflicting) {
|
|
861
|
+
throw extensionError("EXTENSION_TARGET_CONFLICT", `Extensions ${conflicting.id} and ${plan.id} have overlapping ownership at ${artifact.target}`, {
|
|
862
|
+
extension: plan.id,
|
|
863
|
+
conflictingExtension: conflicting.id,
|
|
864
|
+
target: artifact.target,
|
|
865
|
+
...(artifact.selector ? { selector: artifact.selector } : {}),
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
for (const artifact of [...plan.artifacts, ...(installed?.artifacts || [])]) {
|
|
870
|
+
const kind = artifact.kind || "file";
|
|
871
|
+
const target = assertSafeWorkspaceTarget(root, targetForArtifact(artifact), { directory: kind === "directory" });
|
|
872
|
+
if (!["file", "directory"].includes(kind)) continue;
|
|
873
|
+
if (!fs.existsSync(target)) continue;
|
|
874
|
+
const own = ownArtifacts.get(artifact.target);
|
|
875
|
+
if (!own) throw extensionError("EXTENSION_TARGET_OCCUPIED", `Extension target already exists and is not owned by ${plan.id}: ${artifact.target}`, { extension: plan.id, target: artifact.target });
|
|
876
|
+
const actualSha256 = kind === "directory" ? directoryDigest(target) : sha256(fs.readFileSync(target));
|
|
877
|
+
if (actualSha256 !== own.installedSha256) {
|
|
878
|
+
throw extensionError("EXTENSION_ARTIFACT_MODIFIED", `Installed extension artifact contains local changes: ${artifact.target}`, { extension: plan.id, target: artifact.target, expectedSha256: own.installedSha256, actualSha256 });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function recordFailedAttempt(root, plan, error, options = {}) {
|
|
884
|
+
let transaction;
|
|
885
|
+
try {
|
|
886
|
+
const state = loadExtensionState(root);
|
|
887
|
+
const next = structuredClone(state);
|
|
888
|
+
const previous = next.extensions[plan.id]?.installed ?? null;
|
|
889
|
+
next.extensions[plan.id] = {
|
|
890
|
+
installed: previous,
|
|
891
|
+
lastAttempt: { version: plan.version, extensionSpecVersion: plan.extensionSpecVersion, status: "failed", code: error.code || "EXTENSION_INIT_FAILED", message: error.message },
|
|
892
|
+
};
|
|
893
|
+
transaction = createFileTransaction([extensionStatePath(root)]);
|
|
894
|
+
saveExtensionState(root, next, options);
|
|
895
|
+
options.injectFailure?.("after-failure-state-save", plan);
|
|
896
|
+
const verified = loadExtensionState(root);
|
|
897
|
+
const attempt = verified.extensions[plan.id]?.lastAttempt;
|
|
898
|
+
if (!attempt || attempt.status !== "failed" || attempt.version !== plan.version || attempt.extensionSpecVersion !== plan.extensionSpecVersion || attempt.code !== (error.code || "EXTENSION_INIT_FAILED")) {
|
|
899
|
+
throw extensionError("EXTENSION_STATE_VERIFY_FAILED", `Failed extension attempt state could not be verified for ${plan.id}`, { extension: plan.id });
|
|
900
|
+
}
|
|
901
|
+
transaction.commit();
|
|
902
|
+
return true;
|
|
903
|
+
} catch (stateError) {
|
|
904
|
+
transaction?.rollback(stateError);
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function createDirectoryTransition(root, previousInstalled, nextInstalled, verifiedById) {
|
|
910
|
+
const previous = new Map(directoryArtifacts(previousInstalled).map((artifact) => [artifact.target, artifact]));
|
|
911
|
+
const next = new Map(directoryArtifacts(nextInstalled).map((artifact) => [artifact.target, artifact]));
|
|
912
|
+
const operations = [];
|
|
913
|
+
for (const [target, artifact] of previous) {
|
|
914
|
+
const directory = assertSafeWorkspaceTarget(root, target, { directory: true });
|
|
915
|
+
if (!fs.existsSync(directory) || directoryDigest(directory) !== artifact.installedSha256) {
|
|
916
|
+
throw extensionError("EXTENSION_ARTIFACT_MODIFIED", `Installed extension directory contains local changes: ${target}`, { target, expectedSha256: artifact.installedSha256, actualSha256: fs.existsSync(directory) ? directoryDigest(directory) : null });
|
|
917
|
+
}
|
|
918
|
+
if (!next.has(target)) operations.push({ target, directory, source: null });
|
|
919
|
+
}
|
|
920
|
+
for (const [target, artifact] of next) {
|
|
921
|
+
const verified = verifiedById.get(artifact.id);
|
|
922
|
+
if (!verified?.directory) throw extensionError("EXTENSION_ARTIFACT_MISSING", `Missing verified directory ${artifact.id}`, { artifact: artifact.id });
|
|
923
|
+
operations.push({ target, directory: path.join(root, ...target.split("/")), source: verified.directory });
|
|
924
|
+
}
|
|
925
|
+
const applied = [];
|
|
926
|
+
return {
|
|
927
|
+
targets: operations.map((operation) => operation.target),
|
|
928
|
+
apply() {
|
|
929
|
+
for (const operation of operations) {
|
|
930
|
+
fs.mkdirSync(path.dirname(operation.directory), { recursive: true });
|
|
931
|
+
const suffix = `${process.pid}.${Date.now()}.${applied.length}`;
|
|
932
|
+
const backup = `${operation.directory}.code-workspace-backup.${suffix}`;
|
|
933
|
+
const staging = `${operation.directory}.code-workspace-staging.${suffix}`;
|
|
934
|
+
const ownsPrevious = previous.has(operation.target);
|
|
935
|
+
let existed = false;
|
|
936
|
+
let backedUp = false;
|
|
937
|
+
try {
|
|
938
|
+
if (operation.source) fs.cpSync(operation.source, staging, { recursive: true, errorOnExist: true, preserveTimestamps: false });
|
|
939
|
+
existed = fs.existsSync(operation.directory);
|
|
940
|
+
if (existed && !ownsPrevious) throw extensionError("EXTENSION_TARGET_OCCUPIED", `Extension target appeared while preparing installation: ${operation.target}`, { target: operation.target });
|
|
941
|
+
if (existed) {
|
|
942
|
+
fs.renameSync(operation.directory, backup);
|
|
943
|
+
backedUp = true;
|
|
944
|
+
}
|
|
945
|
+
if (operation.source) fs.renameSync(staging, operation.directory);
|
|
946
|
+
applied.push({ ...operation, backup: existed ? backup : null, staging });
|
|
947
|
+
} catch (error) {
|
|
948
|
+
if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
|
|
949
|
+
if (backedUp && fs.existsSync(backup)) fs.renameSync(backup, operation.directory);
|
|
950
|
+
throw error;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
},
|
|
954
|
+
verify() {
|
|
955
|
+
for (const operation of operations) {
|
|
956
|
+
if (!operation.source) {
|
|
957
|
+
if (fs.existsSync(operation.directory)) throw extensionError("EXTENSION_POSTCONDITION_FAILED", `Obsolete extension directory was not removed: ${operation.target}`, { target: operation.target });
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
const expected = directoryDigest(operation.source);
|
|
961
|
+
if (!fs.existsSync(operation.directory) || directoryDigest(operation.directory) !== expected) throw extensionError("EXTENSION_POSTCONDITION_FAILED", `Extension directory could not be verified: ${operation.target}`, { target: operation.target });
|
|
962
|
+
}
|
|
963
|
+
},
|
|
964
|
+
commit() {
|
|
965
|
+
for (const operation of applied) if (operation.backup && fs.existsSync(operation.backup)) fs.rmSync(operation.backup, { recursive: true, force: true });
|
|
966
|
+
},
|
|
967
|
+
rollback(error) {
|
|
968
|
+
const rollbackErrors = [];
|
|
969
|
+
for (const operation of applied.slice().reverse()) {
|
|
970
|
+
try {
|
|
971
|
+
if (fs.existsSync(operation.directory)) fs.rmSync(operation.directory, { recursive: true, force: true });
|
|
972
|
+
if (operation.backup && fs.existsSync(operation.backup)) fs.renameSync(operation.backup, operation.directory);
|
|
973
|
+
} catch (rollbackError) {
|
|
974
|
+
rollbackErrors.push(`${operation.target}: ${rollbackError.message}`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (rollbackErrors.length > 0 && error) {
|
|
978
|
+
error.details = { ...(error.details || {}), workspaceRolledBack: false, rollbackErrors: [...(error.details?.rollbackErrors || []), ...rollbackErrors] };
|
|
979
|
+
}
|
|
980
|
+
},
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function installVerifiedArtifacts(root, plan, verified, previousState, options = {}) {
|
|
985
|
+
const previousInstalled = previousState.extensions[plan.id]?.installed || null;
|
|
986
|
+
for (const artifact of [...plan.artifacts, ...(previousInstalled?.artifacts || [])]) assertSafeWorkspaceTarget(root, targetForArtifact(artifact), { directory: artifact.kind === "directory" });
|
|
987
|
+
const verifiedById = new Map(verified.map((artifact) => [artifact.id, artifact]));
|
|
988
|
+
const installedArtifacts = plan.artifacts.map((artifact) => installedRecord(artifact, verifiedById.get(artifact.id)));
|
|
989
|
+
const next = structuredClone(previousState);
|
|
990
|
+
next.extensions[plan.id] = {
|
|
991
|
+
installed: {
|
|
992
|
+
protocolVersion: 3,
|
|
993
|
+
extensionSpecVersion: plan.extensionSpecVersion,
|
|
994
|
+
version: plan.version,
|
|
995
|
+
manifestSha256: plan.manifestSha256,
|
|
996
|
+
packageSha256: plan.packageSha256,
|
|
997
|
+
artifacts: installedArtifacts,
|
|
998
|
+
},
|
|
999
|
+
lastAttempt: { version: plan.version, extensionSpecVersion: plan.extensionSpecVersion, status: "installed" },
|
|
1000
|
+
};
|
|
1001
|
+
const transition = planArtifactTransition(root, plan.id, previousInstalled, next.extensions[plan.id].installed, previousState, next, verifiedById);
|
|
1002
|
+
const directoryTransition = createDirectoryTransition(root, previousInstalled, next.extensions[plan.id].installed, verifiedById);
|
|
1003
|
+
const stateFile = extensionStatePath(root);
|
|
1004
|
+
const transaction = (options.createFileTransaction || createFileTransaction)([...transition.writes.keys(), ...transition.removes, stateFile]);
|
|
1005
|
+
try {
|
|
1006
|
+
directoryTransition.apply();
|
|
1007
|
+
options.injectFailure?.("after-directory-write", plan);
|
|
1008
|
+
for (const [file, content] of transition.writes) {
|
|
1009
|
+
(options.atomicWrite || atomicWrite)(file, content);
|
|
1010
|
+
options.injectFailure?.("after-artifact-write", { plan, file });
|
|
1011
|
+
}
|
|
1012
|
+
for (const file of transition.removes) if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
1013
|
+
saveExtensionState(root, next, options);
|
|
1014
|
+
options.injectFailure?.("after-state-save", plan);
|
|
1015
|
+
verifyArtifactTransition(transition);
|
|
1016
|
+
directoryTransition.verify();
|
|
1017
|
+
const persisted = loadExtensionState(root).extensions[plan.id];
|
|
1018
|
+
if (!persisted || persisted.lastAttempt?.status !== "installed" || persisted.installed?.extensionSpecVersion !== plan.extensionSpecVersion || persisted.installed?.manifestSha256 !== plan.manifestSha256 || persisted.installed?.packageSha256 !== plan.packageSha256) {
|
|
1019
|
+
throw extensionError("EXTENSION_STATE_VERIFY_FAILED", `Installed extension state could not be verified for ${plan.id}`, { extension: plan.id });
|
|
1020
|
+
}
|
|
1021
|
+
options.injectFailure?.("after-verify", plan);
|
|
1022
|
+
transaction.commit();
|
|
1023
|
+
directoryTransition.commit();
|
|
1024
|
+
return persisted.installed;
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
transaction.rollback(error);
|
|
1027
|
+
directoryTransition.rollback(error);
|
|
1028
|
+
if (error.details?.workspaceRolledBack === false) {
|
|
1029
|
+
throw extensionError("EXTENSION_ROLLBACK_INCOMPLETE", `Extension ${plan.id} failed and rollback was incomplete`, { extension: plan.id, causeCode: error.code || "EXTENSION_INSTALL_FAILED", causeMessage: error.message, rollbackErrors: error.details.rollbackErrors });
|
|
1030
|
+
}
|
|
1031
|
+
throw error;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function executeExtension(root, plan, context, options = {}) {
|
|
1036
|
+
let temporaryRoot;
|
|
1037
|
+
let executionResult;
|
|
1038
|
+
try {
|
|
1039
|
+
const currentManifestSha = sha256(fs.readFileSync(plan.manifestFile));
|
|
1040
|
+
if (currentManifestSha !== plan.manifestSha256) throw extensionError("EXTENSION_PLAN_STALE", `Extension manifest changed after planning: ${plan.id}`, { extension: plan.id });
|
|
1041
|
+
const currentEntrySha = sha256(fs.readFileSync(plan.entryFile));
|
|
1042
|
+
if (currentEntrySha !== plan.entrySha256 || currentEntrySha !== plan.manifest.entrySha256) {
|
|
1043
|
+
throw extensionError("EXTENSION_PLAN_STALE", `Extension entry changed after planning: ${plan.id}`, { extension: plan.id, expectedSha256: plan.entrySha256, actualSha256: currentEntrySha });
|
|
1044
|
+
}
|
|
1045
|
+
const currentPackageSha = directoryDigest(plan.sourceRoot);
|
|
1046
|
+
if (currentPackageSha !== plan.packageSha256) throw extensionError("EXTENSION_PLAN_STALE", `Extension package changed after planning: ${plan.id}`, { extension: plan.id, expectedSha256: plan.packageSha256, actualSha256: currentPackageSha });
|
|
1047
|
+
const currentManifest = validateManifest(readJson(plan.manifestFile, "EXTENSION_MANIFEST_PARSE_FAILED"), {
|
|
1048
|
+
expectedId: plan.id,
|
|
1049
|
+
expectedVersion: plan.version,
|
|
1050
|
+
});
|
|
1051
|
+
if (currentManifest.extensionSpecVersion !== plan.extensionSpecVersion) {
|
|
1052
|
+
throw extensionError("EXTENSION_PLAN_STALE", `Extension Spec changed after planning: ${plan.id}`, { extension: plan.id, expectedExtensionSpecVersion: plan.extensionSpecVersion, actualExtensionSpecVersion: currentManifest.extensionSpecVersion });
|
|
1053
|
+
}
|
|
1054
|
+
const state = loadExtensionState(root);
|
|
1055
|
+
const installed = state.extensions[plan.id]?.installed || null;
|
|
1056
|
+
if (installedIsCurrent(root, plan, installed, state)) {
|
|
1057
|
+
executionResult = { id: plan.id, version: plan.version, extensionSpecVersion: plan.extensionSpecVersion, status: "skipped", reason: "current" };
|
|
1058
|
+
return executionResult;
|
|
1059
|
+
}
|
|
1060
|
+
assertInstallOwnership(root, plan, state);
|
|
1061
|
+
const beforeState = stateFingerprint(root);
|
|
1062
|
+
temporaryRoot = fs.mkdtempSync(path.join(options.tempRoot || os.tmpdir(), `code-workspace-extension-${plan.id}-`));
|
|
1063
|
+
const outputRoot = path.join(temporaryRoot, "output");
|
|
1064
|
+
const contextFile = path.join(temporaryRoot, "context.json");
|
|
1065
|
+
const resultFile = path.join(temporaryRoot, "result.json");
|
|
1066
|
+
fs.mkdirSync(outputRoot);
|
|
1067
|
+
const runtimeContext = validateInitContext(context, plan);
|
|
1068
|
+
fs.writeFileSync(contextFile, `${JSON.stringify(runtimeContext, null, 2)}\n`, { mode: 0o600 });
|
|
1069
|
+
runExtensionProcess(plan, contextFile, outputRoot, resultFile, options);
|
|
1070
|
+
options.injectFailure?.("after-process", plan);
|
|
1071
|
+
assertRegularFile(resultFile, "EXTENSION_RESULT_MISSING", "extension init result");
|
|
1072
|
+
const result = readJson(resultFile, "EXTENSION_RESULT_INVALID");
|
|
1073
|
+
const verified = verifyExtensionOutput(outputRoot, result, plan);
|
|
1074
|
+
options.injectFailure?.("after-output-verify", plan);
|
|
1075
|
+
if (stateFingerprint(root) !== beforeState) throw extensionError("EXTENSION_STATE_CONFLICT", `Extension state changed while ${plan.id} was running`, { extension: plan.id });
|
|
1076
|
+
const installedState = installVerifiedArtifacts(root, plan, verified, state, options);
|
|
1077
|
+
executionResult = { id: plan.id, version: plan.version, extensionSpecVersion: plan.extensionSpecVersion, status: "installed", artifacts: installedState.artifacts };
|
|
1078
|
+
return executionResult;
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
const normalized = error.code ? error : extensionError("EXTENSION_INIT_FAILED", `Extension ${plan.id} failed: ${error.message}`, { extension: plan.id, cause: error.name });
|
|
1081
|
+
const statePersisted = recordFailedAttempt(root, plan, normalized, options);
|
|
1082
|
+
executionResult = { id: plan.id, version: plan.version, extensionSpecVersion: plan.extensionSpecVersion, status: "failed", code: normalized.code, message: normalized.message, statePersisted };
|
|
1083
|
+
return executionResult;
|
|
1084
|
+
} finally {
|
|
1085
|
+
if (temporaryRoot) {
|
|
1086
|
+
try {
|
|
1087
|
+
(options.rmSync || fs.rmSync)(temporaryRoot, { recursive: true, force: true });
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
if (executionResult) {
|
|
1090
|
+
executionResult.warnings = [{
|
|
1091
|
+
code: "EXTENSION_STAGING_CLEANUP_FAILED",
|
|
1092
|
+
message: `Could not remove extension staging directory for ${plan.id}: ${error.message}`,
|
|
1093
|
+
}];
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function runExtensionBatch(root, plans, context, options = {}) {
|
|
1101
|
+
const executed = [];
|
|
1102
|
+
for (const plan of plans) {
|
|
1103
|
+
const executionContext = typeof context === "function" ? context(plan) : {
|
|
1104
|
+
...(context || {}),
|
|
1105
|
+
extensionSpecVersion: plan.extensionSpecVersion,
|
|
1106
|
+
extension: { id: plan.id, version: plan.version },
|
|
1107
|
+
};
|
|
1108
|
+
executed.push(executeExtension(root, plan, executionContext, options));
|
|
1109
|
+
}
|
|
1110
|
+
const requested = options.requested || plans.map((plan) => plan.id);
|
|
1111
|
+
const byId = new Map([...executed, ...(options.preFailures || [])].map((entry) => [entry.id, entry]));
|
|
1112
|
+
const results = requested.map((id) => byId.get(id)).filter(Boolean);
|
|
1113
|
+
return {
|
|
1114
|
+
requested,
|
|
1115
|
+
results,
|
|
1116
|
+
summary: {
|
|
1117
|
+
installed: results.filter((entry) => entry.status === "installed").length,
|
|
1118
|
+
skipped: results.filter((entry) => entry.status === "skipped").length,
|
|
1119
|
+
failed: results.filter((entry) => entry.status === "failed").length,
|
|
1120
|
+
},
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function planExtensionUninstall(root, id) {
|
|
1125
|
+
const extensionId = validateExtensionName(id);
|
|
1126
|
+
const state = loadExtensionState(root);
|
|
1127
|
+
const installed = state.extensions[extensionId]?.installed || null;
|
|
1128
|
+
if (!installed) return Object.freeze({ root: path.resolve(root), id: extensionId, action: "skip", reason: "not-installed", targets: [] });
|
|
1129
|
+
for (const artifact of installed.artifacts) assertSafeWorkspaceTarget(root, targetForArtifact(artifact), { directory: artifact.kind === "directory" });
|
|
1130
|
+
const next = structuredClone(state);
|
|
1131
|
+
delete next.extensions[extensionId];
|
|
1132
|
+
const transition = planArtifactTransition(root, extensionId, installed, null, state, next);
|
|
1133
|
+
createDirectoryTransition(root, installed, null, new Map());
|
|
1134
|
+
const directoryTargets = directoryArtifacts(installed).map((artifact) => artifact.target);
|
|
1135
|
+
return Object.freeze({
|
|
1136
|
+
root: path.resolve(root),
|
|
1137
|
+
id: extensionId,
|
|
1138
|
+
action: "remove",
|
|
1139
|
+
version: installed.version,
|
|
1140
|
+
stateFingerprint: stateFingerprint(root),
|
|
1141
|
+
state,
|
|
1142
|
+
next,
|
|
1143
|
+
transition,
|
|
1144
|
+
targets: Object.freeze([...new Set([...transition.writes.keys(), ...transition.removes])].map((file) => path.relative(path.resolve(root), file).split(path.sep).join("/")).concat(directoryTargets)),
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function applyExtensionUninstall(plan, options = {}) {
|
|
1149
|
+
if (plan.action === "skip") return { id: plan.id, status: "skipped", reason: plan.reason, removed: [] };
|
|
1150
|
+
if (stateFingerprint(plan.root) !== plan.stateFingerprint) {
|
|
1151
|
+
throw extensionError("EXTENSION_STATE_CONFLICT", `Extension state changed before uninstalling ${plan.id}`, { extension: plan.id });
|
|
1152
|
+
}
|
|
1153
|
+
const currentState = loadExtensionState(plan.root);
|
|
1154
|
+
const installed = currentState.extensions[plan.id]?.installed;
|
|
1155
|
+
if (!installed || installed.version !== plan.version) throw extensionError("EXTENSION_STATE_CONFLICT", `Installed extension changed before uninstalling ${plan.id}`, { extension: plan.id });
|
|
1156
|
+
const next = structuredClone(currentState);
|
|
1157
|
+
delete next.extensions[plan.id];
|
|
1158
|
+
const transition = planArtifactTransition(plan.root, plan.id, installed, null, currentState, next);
|
|
1159
|
+
const directoryTransition = createDirectoryTransition(plan.root, installed, null, new Map());
|
|
1160
|
+
const stateFile = extensionStatePath(plan.root);
|
|
1161
|
+
const transaction = (options.createFileTransaction || createFileTransaction)([...transition.writes.keys(), ...transition.removes, stateFile]);
|
|
1162
|
+
try {
|
|
1163
|
+
directoryTransition.apply();
|
|
1164
|
+
for (const [file, content] of transition.writes) (options.atomicWrite || atomicWrite)(file, content);
|
|
1165
|
+
options.injectFailure?.("after-uninstall-write", plan);
|
|
1166
|
+
for (const file of transition.removes) if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
1167
|
+
options.injectFailure?.("after-uninstall-remove", plan);
|
|
1168
|
+
saveExtensionState(plan.root, next, options);
|
|
1169
|
+
options.injectFailure?.("after-uninstall-state", plan);
|
|
1170
|
+
verifyArtifactTransition(transition);
|
|
1171
|
+
directoryTransition.verify();
|
|
1172
|
+
if (loadExtensionState(plan.root).extensions[plan.id]) throw extensionError("EXTENSION_STATE_VERIFY_FAILED", `Uninstalled extension state still exists for ${plan.id}`, { extension: plan.id });
|
|
1173
|
+
options.injectFailure?.("after-uninstall-verify", plan);
|
|
1174
|
+
for (const file of transition.removes) removeEmptyParents(plan.root, file);
|
|
1175
|
+
transaction.commit();
|
|
1176
|
+
directoryTransition.commit();
|
|
1177
|
+
return { id: plan.id, version: plan.version, status: "uninstalled", removed: plan.targets };
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
transaction.rollback(error);
|
|
1180
|
+
directoryTransition.rollback(error);
|
|
1181
|
+
throw error;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
module.exports = {
|
|
1186
|
+
EXTENSIONS_ROOT,
|
|
1187
|
+
EXTENSION_NAME_PATTERN,
|
|
1188
|
+
EXTENSION_STATE_FILE,
|
|
1189
|
+
SUPPORTED_EXTENSION_SPEC_VERSIONS,
|
|
1190
|
+
applicableArtifacts,
|
|
1191
|
+
applyExtensionUninstall,
|
|
1192
|
+
assertSafeWorkspaceTarget,
|
|
1193
|
+
compareSemver,
|
|
1194
|
+
coreManagedTargets,
|
|
1195
|
+
discoverExtensions,
|
|
1196
|
+
emptyExtensionState,
|
|
1197
|
+
executeExtension,
|
|
1198
|
+
extensionStatePath,
|
|
1199
|
+
hasWorkspaceConfiguration,
|
|
1200
|
+
inspectExtensionState,
|
|
1201
|
+
installedExtensionNames,
|
|
1202
|
+
loadExtensionState,
|
|
1203
|
+
normalizeArtifactTarget,
|
|
1204
|
+
normalizeExtensionNames,
|
|
1205
|
+
parseExtensionSelection,
|
|
1206
|
+
parseSemver,
|
|
1207
|
+
planExtensionUninstall,
|
|
1208
|
+
prepareExtensionPlans,
|
|
1209
|
+
resolveExtensionPlans,
|
|
1210
|
+
runExtensionBatch,
|
|
1211
|
+
saveExtensionState,
|
|
1212
|
+
supportsExtensionSpec,
|
|
1213
|
+
validateManifest,
|
|
1214
|
+
validateManifestEnvelope,
|
|
1215
|
+
verifyExtensionOutput,
|
|
1216
|
+
};
|