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,180 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
const TOML = require("@iarna/toml");
|
|
5
|
+
|
|
6
|
+
const { WorkspaceError } = require("./errors");
|
|
7
|
+
const { sha256 } = require("./fs");
|
|
8
|
+
|
|
9
|
+
const CODEX_CONFIG_TARGET = ".codex/config.toml";
|
|
10
|
+
const CODEX_HOOKS_TARGET = ".codex/hooks.json";
|
|
11
|
+
const LEGACY_ARTIFACT_KINDS = new Set(["codex-config-block", "codex-hooks"]);
|
|
12
|
+
|
|
13
|
+
function artifactError(code, message, details = {}) {
|
|
14
|
+
return new WorkspaceError(code, message, details);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function canonicalize(value) {
|
|
18
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
19
|
+
if (!value || typeof value !== "object") return value;
|
|
20
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function canonicalJson(value) {
|
|
24
|
+
return JSON.stringify(canonicalize(value));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function targetForLegacyArtifact(artifact) {
|
|
28
|
+
if (artifact.kind === "codex-config-block") return CODEX_CONFIG_TARGET;
|
|
29
|
+
if (artifact.kind === "codex-hooks") return CODEX_HOOKS_TARGET;
|
|
30
|
+
return artifact.target;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function configMarkers(extensionId, artifactId) {
|
|
34
|
+
return {
|
|
35
|
+
start: `# BEGIN code-workspace-extension:${extensionId}:${artifactId}`,
|
|
36
|
+
end: `# END code-workspace-extension:${extensionId}:${artifactId}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findConfigBlock(text, extensionId, artifactId) {
|
|
41
|
+
const markers = configMarkers(extensionId, artifactId);
|
|
42
|
+
const start = text.indexOf(`${markers.start}\n`);
|
|
43
|
+
if (start < 0) return null;
|
|
44
|
+
const contentStart = start + markers.start.length + 1;
|
|
45
|
+
const end = text.indexOf(markers.end, contentStart);
|
|
46
|
+
if (end < 0 || text.indexOf(`${markers.start}\n`, contentStart) >= 0) {
|
|
47
|
+
throw artifactError("EXTENSION_CONFIG_BLOCK_MODIFIED", `Extension config block markers are invalid for ${extensionId}/${artifactId}`, { extension: extensionId, artifact: artifactId });
|
|
48
|
+
}
|
|
49
|
+
const after = end + markers.end.length;
|
|
50
|
+
return { start, end: text[after] === "\n" ? after + 1 : after, fragment: text.slice(contentStart, end) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function removeConfigBlock(text, extensionId, artifact) {
|
|
54
|
+
const found = findConfigBlock(text, extensionId, artifact.id);
|
|
55
|
+
if (!found || sha256(Buffer.from(found.fragment)) !== artifact.installedSha256) {
|
|
56
|
+
throw artifactError("EXTENSION_CONFIG_BLOCK_MODIFIED", `Installed extension config block contains local changes: ${extensionId}/${artifact.id}`, { extension: extensionId, artifact: artifact.id });
|
|
57
|
+
}
|
|
58
|
+
return `${text.slice(0, found.start)}${text.slice(found.end)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readHooks(file) {
|
|
62
|
+
if (!fs.existsSync(file)) return { hooks: {} };
|
|
63
|
+
try {
|
|
64
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !value.hooks || typeof value.hooks !== "object" || Array.isArray(value.hooks)) throw new Error("hooks must be an object");
|
|
66
|
+
return structuredClone(value);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw artifactError("EXTENSION_HOOKS_TARGET_INVALID", `Cannot parse ${CODEX_HOOKS_TARGET}: ${error.message}`, { target: CODEX_HOOKS_TARGET });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hookArtifacts(state) {
|
|
73
|
+
const entries = [];
|
|
74
|
+
for (const [extensionId, value] of Object.entries(state.extensions || {})) {
|
|
75
|
+
for (const artifact of value.installed?.artifacts || []) {
|
|
76
|
+
if (artifact.kind === "codex-hooks") entries.push({ extensionId, artifact });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return entries.sort((left, right) => left.extensionId.localeCompare(right.extensionId) || left.artifact.id.localeCompare(right.artifact.id));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function removeHookContribution(document, extensionId, artifact) {
|
|
83
|
+
for (const [event, entries] of Object.entries(artifact.payload?.hooks || {})) {
|
|
84
|
+
const current = document.hooks[event];
|
|
85
|
+
if (!Array.isArray(current)) throw artifactError("EXTENSION_HOOKS_MODIFIED", `Installed Hook contribution is missing for ${extensionId}/${artifact.id}`, { extension: extensionId, artifact: artifact.id, event });
|
|
86
|
+
for (const expected of entries) {
|
|
87
|
+
const matches = current.map((entry, index) => canonicalJson(entry) === canonicalJson(expected) ? index : -1).filter((index) => index >= 0);
|
|
88
|
+
if (matches.length !== 1) throw artifactError("EXTENSION_HOOKS_MODIFIED", `Installed Hook contribution is missing or ambiguous for ${extensionId}/${artifact.id}`, { extension: extensionId, artifact: artifact.id, event });
|
|
89
|
+
current.splice(matches[0], 1);
|
|
90
|
+
}
|
|
91
|
+
if (current.length === 0) delete document.hooks[event];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function addHookContribution(document, extensionId, artifact) {
|
|
96
|
+
for (const [event, entries] of Object.entries(artifact.payload?.hooks || {})) {
|
|
97
|
+
const current = document.hooks[event] ||= [];
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (current.some((existing) => canonicalJson(existing) === canonicalJson(entry))) {
|
|
100
|
+
throw artifactError("EXTENSION_HOOK_CONFLICT", `Hook contribution conflicts for ${extensionId}/${artifact.id}`, { extension: extensionId, artifact: artifact.id, event });
|
|
101
|
+
}
|
|
102
|
+
current.push(structuredClone(entry));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function composeHooks(root, previousState, nextState) {
|
|
108
|
+
const file = path.join(root, CODEX_HOOKS_TARGET);
|
|
109
|
+
const document = readHooks(file);
|
|
110
|
+
for (const { extensionId, artifact } of hookArtifacts(previousState)) removeHookContribution(document, extensionId, artifact);
|
|
111
|
+
for (const { extensionId, artifact } of hookArtifacts(nextState)) addHookContribution(document, extensionId, artifact);
|
|
112
|
+
const emptyOwnedDocument = Object.keys(document.hooks).length === 0 && Object.keys(document).every((key) => key === "hooks");
|
|
113
|
+
return { file, content: emptyOwnedDocument ? null : `${JSON.stringify(document, null, 2)}\n` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function composeHookContent(baseContent, state) {
|
|
117
|
+
let document;
|
|
118
|
+
try {
|
|
119
|
+
document = JSON.parse(Buffer.isBuffer(baseContent) ? baseContent.toString("utf8") : String(baseContent));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
throw artifactError("EXTENSION_HOOKS_TARGET_INVALID", `Cannot parse core Hook content: ${error.message}`, { target: CODEX_HOOKS_TARGET });
|
|
122
|
+
}
|
|
123
|
+
for (const { extensionId, artifact } of hookArtifacts(state)) addHookContribution(document, extensionId, artifact);
|
|
124
|
+
return Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function legacyConfigArtifacts(installed) {
|
|
128
|
+
return (installed?.artifacts || []).filter((artifact) => artifact.kind === "codex-config-block");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function legacyHooksArtifacts(installed) {
|
|
132
|
+
return (installed?.artifacts || []).filter((artifact) => artifact.kind === "codex-hooks");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function planLegacyArtifactTransition(root, extensionId, previousInstalled, nextInstalled, previousState, nextState) {
|
|
136
|
+
const writes = new Map();
|
|
137
|
+
const removes = new Set();
|
|
138
|
+
const previousConfig = legacyConfigArtifacts(previousInstalled);
|
|
139
|
+
const nextConfig = legacyConfigArtifacts(nextInstalled);
|
|
140
|
+
if (previousConfig.length > 0 || nextConfig.length > 0) {
|
|
141
|
+
const file = path.join(root, CODEX_CONFIG_TARGET);
|
|
142
|
+
let content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
143
|
+
for (const artifact of previousConfig) content = removeConfigBlock(content, extensionId, artifact);
|
|
144
|
+
if (nextConfig.length > 0) throw artifactError("EXTENSION_STATE_INVALID", "Legacy config blocks cannot be newly installed by Extension Spec v1", { extension: extensionId });
|
|
145
|
+
if (content) {
|
|
146
|
+
try { TOML.parse(content); } catch (error) { throw artifactError("EXTENSION_CONFIG_CONFLICT", `Removing legacy config block produces invalid TOML: ${error.message}`, { extension: extensionId, target: CODEX_CONFIG_TARGET }); }
|
|
147
|
+
writes.set(file, content);
|
|
148
|
+
} else removes.add(file);
|
|
149
|
+
}
|
|
150
|
+
if (legacyHooksArtifacts(previousInstalled).length > 0 || legacyHooksArtifacts(nextInstalled).length > 0) {
|
|
151
|
+
const hooks = composeHooks(root, previousState, nextState);
|
|
152
|
+
if (hooks.content === null) removes.add(hooks.file);
|
|
153
|
+
else writes.set(hooks.file, hooks.content);
|
|
154
|
+
}
|
|
155
|
+
return { writes, removes };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function legacyArtifactsCurrent(root, extensionId, installed, state) {
|
|
159
|
+
try {
|
|
160
|
+
const config = fs.existsSync(path.join(root, CODEX_CONFIG_TARGET)) ? fs.readFileSync(path.join(root, CODEX_CONFIG_TARGET), "utf8") : "";
|
|
161
|
+
for (const artifact of legacyConfigArtifacts(installed)) {
|
|
162
|
+
const found = findConfigBlock(config, extensionId, artifact.id);
|
|
163
|
+
if (!found || sha256(Buffer.from(found.fragment)) !== artifact.installedSha256) return false;
|
|
164
|
+
}
|
|
165
|
+
if (legacyHooksArtifacts(installed).length > 0) composeHooks(root, state, state);
|
|
166
|
+
return true;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
CODEX_CONFIG_TARGET,
|
|
174
|
+
CODEX_HOOKS_TARGET,
|
|
175
|
+
LEGACY_ARTIFACT_KINDS,
|
|
176
|
+
composeHookContent,
|
|
177
|
+
legacyArtifactsCurrent,
|
|
178
|
+
planLegacyArtifactTransition,
|
|
179
|
+
targetForLegacyArtifact,
|
|
180
|
+
};
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
const TOML = require("@iarna/toml");
|
|
5
|
+
|
|
6
|
+
const { directoryDigest } = require("./directory-digest");
|
|
7
|
+
const { WorkspaceError } = require("./errors");
|
|
8
|
+
const { sha256 } = require("./fs");
|
|
9
|
+
const {
|
|
10
|
+
CODEX_CONFIG_TARGET,
|
|
11
|
+
CODEX_HOOKS_TARGET,
|
|
12
|
+
LEGACY_ARTIFACT_KINDS,
|
|
13
|
+
composeHookContent,
|
|
14
|
+
legacyArtifactsCurrent,
|
|
15
|
+
planLegacyArtifactTransition,
|
|
16
|
+
targetForLegacyArtifact,
|
|
17
|
+
} = require("./extension-artifacts-legacy");
|
|
18
|
+
|
|
19
|
+
const ARTIFACT_KINDS = new Set(["file", "directory", "text-block", "json-member"]);
|
|
20
|
+
|
|
21
|
+
function artifactError(code, message, details = {}) {
|
|
22
|
+
return new WorkspaceError(code, message, details);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function canonicalize(value) {
|
|
26
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
27
|
+
if (!value || typeof value !== "object") return value;
|
|
28
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function canonicalJson(value) {
|
|
32
|
+
return JSON.stringify(canonicalize(value));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function targetForArtifact(artifact) {
|
|
36
|
+
return LEGACY_ARTIFACT_KINDS.has(artifact.kind) ? targetForLegacyArtifact(artifact) : artifact.target;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function textMarkers(extensionId, artifactId) {
|
|
40
|
+
return {
|
|
41
|
+
start: `# BEGIN code-workspace-extension:${extensionId}:${artifactId}`,
|
|
42
|
+
end: `# END code-workspace-extension:${extensionId}:${artifactId}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeTextFragment(content, artifact) {
|
|
47
|
+
const text = Buffer.isBuffer(content) ? content.toString("utf8") : String(content);
|
|
48
|
+
if (!text.trim() || !text.endsWith("\n") || text.includes("# BEGIN code-workspace-extension:") || text.includes("# END code-workspace-extension:")) {
|
|
49
|
+
throw artifactError("EXTENSION_TEXT_BLOCK_INVALID", `Extension text block ${artifact.id} must be non-empty, newline-terminated text without Host markers`, { artifact: artifact.id });
|
|
50
|
+
}
|
|
51
|
+
if ((artifact.format || "text") === "toml") {
|
|
52
|
+
try { TOML.parse(text); } catch (error) { throw artifactError("EXTENSION_TEXT_BLOCK_INVALID", `Extension text block ${artifact.id} is invalid TOML: ${error.message}`, { artifact: artifact.id }); }
|
|
53
|
+
}
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function textBlock(extensionId, artifactId, fragment) {
|
|
58
|
+
const markers = textMarkers(extensionId, artifactId);
|
|
59
|
+
return `${markers.start}\n${fragment}${markers.end}\n`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function findTextBlock(text, extensionId, artifactId) {
|
|
63
|
+
const markers = textMarkers(extensionId, artifactId);
|
|
64
|
+
const start = text.indexOf(`${markers.start}\n`);
|
|
65
|
+
if (start < 0) return null;
|
|
66
|
+
const contentStart = start + markers.start.length + 1;
|
|
67
|
+
const end = text.indexOf(markers.end, contentStart);
|
|
68
|
+
if (end < 0 || text.indexOf(`${markers.start}\n`, contentStart) >= 0) {
|
|
69
|
+
throw artifactError("EXTENSION_TEXT_BLOCK_MODIFIED", `Extension text block markers are invalid for ${extensionId}/${artifactId}`, { extension: extensionId, artifact: artifactId });
|
|
70
|
+
}
|
|
71
|
+
const after = end + markers.end.length;
|
|
72
|
+
return { start, end: text[after] === "\n" ? after + 1 : after, fragment: text.slice(contentStart, end) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function removeTextBlock(text, extensionId, artifact) {
|
|
76
|
+
const found = findTextBlock(text, extensionId, artifact.id);
|
|
77
|
+
if (!found || sha256(Buffer.from(found.fragment)) !== artifact.installedSha256) {
|
|
78
|
+
throw artifactError("EXTENSION_TEXT_BLOCK_MODIFIED", `Installed extension text block contains local changes: ${extensionId}/${artifact.id}`, { extension: extensionId, artifact: artifact.id, target: artifact.target });
|
|
79
|
+
}
|
|
80
|
+
return `${text.slice(0, found.start)}${text.slice(found.end)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function decodePointer(selector) {
|
|
84
|
+
if (typeof selector !== "string" || !selector.startsWith("/") || selector === "/") {
|
|
85
|
+
throw artifactError("EXTENSION_JSON_SELECTOR_INVALID", `Invalid JSON member selector: ${selector || "<missing>"}`, { selector: selector || null });
|
|
86
|
+
}
|
|
87
|
+
return selector.slice(1).split("/").map((segment) => {
|
|
88
|
+
if (!segment || /~(?![01])/.test(segment)) throw artifactError("EXTENSION_JSON_SELECTOR_INVALID", `Invalid JSON member selector: ${selector}`, { selector });
|
|
89
|
+
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readJsonDocument(file) {
|
|
94
|
+
if (!fs.existsSync(file)) return {};
|
|
95
|
+
try {
|
|
96
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
97
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("document root must be an object");
|
|
98
|
+
return structuredClone(value);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw artifactError("EXTENSION_JSON_TARGET_INVALID", `Cannot parse shared JSON target ${file}: ${error.message}`, { file });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function removeJsonMember(document, extensionId, artifact) {
|
|
105
|
+
const segments = decodePointer(artifact.selector);
|
|
106
|
+
const parents = [];
|
|
107
|
+
let current = document;
|
|
108
|
+
for (const segment of segments.slice(0, -1)) {
|
|
109
|
+
if (!current[segment] || typeof current[segment] !== "object" || Array.isArray(current[segment])) {
|
|
110
|
+
throw artifactError("EXTENSION_JSON_MEMBER_MODIFIED", `Installed JSON member is missing: ${artifact.selector}`, { extension: extensionId, artifact: artifact.id, selector: artifact.selector });
|
|
111
|
+
}
|
|
112
|
+
parents.push([current, segment]);
|
|
113
|
+
current = current[segment];
|
|
114
|
+
}
|
|
115
|
+
const key = segments.at(-1);
|
|
116
|
+
if (!Object.prototype.hasOwnProperty.call(current, key) || canonicalJson(current[key]) !== canonicalJson(artifact.payload)) {
|
|
117
|
+
throw artifactError("EXTENSION_JSON_MEMBER_MODIFIED", `Installed JSON member contains local changes: ${artifact.selector}`, { extension: extensionId, artifact: artifact.id, selector: artifact.selector });
|
|
118
|
+
}
|
|
119
|
+
delete current[key];
|
|
120
|
+
for (const [parent, segment] of parents.reverse()) {
|
|
121
|
+
const value = parent[segment];
|
|
122
|
+
if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) delete parent[segment];
|
|
123
|
+
else break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function addJsonMember(document, extensionId, artifact) {
|
|
128
|
+
const segments = decodePointer(artifact.selector);
|
|
129
|
+
let current = document;
|
|
130
|
+
for (const segment of segments.slice(0, -1)) {
|
|
131
|
+
if (current[segment] === undefined) current[segment] = {};
|
|
132
|
+
if (!current[segment] || typeof current[segment] !== "object" || Array.isArray(current[segment])) {
|
|
133
|
+
throw artifactError("EXTENSION_JSON_MEMBER_CONFLICT", `JSON selector parent is not an object: ${artifact.selector}`, { extension: extensionId, artifact: artifact.id, selector: artifact.selector });
|
|
134
|
+
}
|
|
135
|
+
current = current[segment];
|
|
136
|
+
}
|
|
137
|
+
const key = segments.at(-1);
|
|
138
|
+
if (Object.prototype.hasOwnProperty.call(current, key)) {
|
|
139
|
+
throw artifactError("EXTENSION_JSON_MEMBER_CONFLICT", `JSON member already exists: ${artifact.selector}`, { extension: extensionId, artifact: artifact.id, selector: artifact.selector });
|
|
140
|
+
}
|
|
141
|
+
current[key] = structuredClone(artifact.payload);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function installedRecord(artifact, verified) {
|
|
145
|
+
const base = {
|
|
146
|
+
id: artifact.id,
|
|
147
|
+
kind: artifact.kind,
|
|
148
|
+
ownership: artifact.ownership,
|
|
149
|
+
target: artifact.target,
|
|
150
|
+
installedSha256: verified.installedSha256,
|
|
151
|
+
};
|
|
152
|
+
if (artifact.kind === "text-block") return { ...base, format: artifact.format || "text" };
|
|
153
|
+
if (artifact.kind === "json-member") {
|
|
154
|
+
let payload;
|
|
155
|
+
try { payload = JSON.parse(verified.content.toString("utf8")); } catch (error) { throw artifactError("EXTENSION_JSON_MEMBER_INVALID", `Cannot parse JSON contribution ${artifact.id}: ${error.message}`, { artifact: artifact.id }); }
|
|
156
|
+
return { ...base, selector: artifact.selector, payload: canonicalize(payload) };
|
|
157
|
+
}
|
|
158
|
+
return base;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function artifactsOfKind(installed, kind) {
|
|
162
|
+
return (installed?.artifacts || []).filter((artifact) => (artifact.kind || "file") === kind);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function mergeTransition(target, source) {
|
|
166
|
+
for (const [file, content] of source.writes) target.writes.set(file, content);
|
|
167
|
+
for (const file of source.removes) target.removes.add(file);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function planFileTransition(root, extensionId, previousInstalled, nextInstalled, verifiedById) {
|
|
171
|
+
const writes = new Map();
|
|
172
|
+
const removes = new Set();
|
|
173
|
+
const previous = new Map(artifactsOfKind(previousInstalled, "file").map((artifact) => [artifact.target, artifact]));
|
|
174
|
+
const next = new Map(artifactsOfKind(nextInstalled, "file").map((artifact) => [artifact.target, artifact]));
|
|
175
|
+
for (const [target, artifact] of previous) {
|
|
176
|
+
const file = path.join(root, ...target.split("/"));
|
|
177
|
+
if (!fs.existsSync(file) || sha256(fs.readFileSync(file)) !== artifact.installedSha256) {
|
|
178
|
+
throw artifactError("EXTENSION_ARTIFACT_MODIFIED", `Installed extension artifact contains local changes: ${target}`, { extension: extensionId, target });
|
|
179
|
+
}
|
|
180
|
+
if (!next.has(target)) removes.add(file);
|
|
181
|
+
}
|
|
182
|
+
for (const [target, artifact] of next) {
|
|
183
|
+
const file = path.join(root, ...target.split("/"));
|
|
184
|
+
if (fs.existsSync(file) && !previous.has(target)) throw artifactError("EXTENSION_TARGET_OCCUPIED", `Extension target already exists and is not owned by ${extensionId}: ${target}`, { extension: extensionId, target });
|
|
185
|
+
const verified = verifiedById.get(artifact.id);
|
|
186
|
+
if (!verified?.content) throw artifactError("EXTENSION_ARTIFACT_MISSING", `Missing verified file artifact ${artifact.id}`, { extension: extensionId, artifact: artifact.id });
|
|
187
|
+
writes.set(file, verified.content);
|
|
188
|
+
}
|
|
189
|
+
return { writes, removes };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function planTextTransition(root, extensionId, previousInstalled, nextInstalled, verifiedById) {
|
|
193
|
+
const transition = { writes: new Map(), removes: new Set() };
|
|
194
|
+
const previous = artifactsOfKind(previousInstalled, "text-block");
|
|
195
|
+
const next = artifactsOfKind(nextInstalled, "text-block");
|
|
196
|
+
const targets = [...new Set([...previous, ...next].map((artifact) => artifact.target))];
|
|
197
|
+
for (const target of targets) {
|
|
198
|
+
const file = path.join(root, ...target.split("/"));
|
|
199
|
+
let content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
200
|
+
for (const artifact of previous.filter((entry) => entry.target === target)) content = removeTextBlock(content, extensionId, artifact);
|
|
201
|
+
for (const artifact of next.filter((entry) => entry.target === target).sort((left, right) => left.id.localeCompare(right.id))) {
|
|
202
|
+
if (findTextBlock(content, extensionId, artifact.id)) throw artifactError("EXTENSION_TEXT_BLOCK_CONFLICT", `Extension text block already exists: ${extensionId}/${artifact.id}`, { extension: extensionId, artifact: artifact.id });
|
|
203
|
+
const verified = verifiedById.get(artifact.id);
|
|
204
|
+
if (!verified?.content) throw artifactError("EXTENSION_ARTIFACT_MISSING", `Missing verified text block ${artifact.id}`, { extension: extensionId, artifact: artifact.id });
|
|
205
|
+
const fragment = normalizeTextFragment(verified.content, artifact);
|
|
206
|
+
const separator = !content ? "" : content.endsWith("\n\n") ? "" : content.endsWith("\n") ? "\n" : "\n\n";
|
|
207
|
+
content = `${content}${separator}${textBlock(extensionId, artifact.id, fragment)}`;
|
|
208
|
+
}
|
|
209
|
+
if ([...previous, ...next].some((artifact) => artifact.target === target && artifact.format === "toml")) {
|
|
210
|
+
try { TOML.parse(content); } catch (error) { throw artifactError("EXTENSION_TEXT_BLOCK_CONFLICT", `Extension text blocks produce invalid TOML: ${error.message}`, { extension: extensionId, target }); }
|
|
211
|
+
}
|
|
212
|
+
if (content) transition.writes.set(file, content);
|
|
213
|
+
else transition.removes.add(file);
|
|
214
|
+
}
|
|
215
|
+
return transition;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function planJsonTransition(root, extensionId, previousInstalled, nextInstalled) {
|
|
219
|
+
const transition = { writes: new Map(), removes: new Set() };
|
|
220
|
+
const previous = artifactsOfKind(previousInstalled, "json-member");
|
|
221
|
+
const next = artifactsOfKind(nextInstalled, "json-member");
|
|
222
|
+
const targets = [...new Set([...previous, ...next].map((artifact) => artifact.target))];
|
|
223
|
+
for (const target of targets) {
|
|
224
|
+
const file = path.join(root, ...target.split("/"));
|
|
225
|
+
const document = readJsonDocument(file);
|
|
226
|
+
for (const artifact of previous.filter((entry) => entry.target === target)) removeJsonMember(document, extensionId, artifact);
|
|
227
|
+
for (const artifact of next.filter((entry) => entry.target === target).sort((left, right) => left.selector.localeCompare(right.selector))) addJsonMember(document, extensionId, artifact);
|
|
228
|
+
if (Object.keys(document).length === 0) transition.removes.add(file);
|
|
229
|
+
else transition.writes.set(file, `${JSON.stringify(document, null, 2)}\n`);
|
|
230
|
+
}
|
|
231
|
+
return transition;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function planArtifactTransition(root, extensionId, previousInstalled, nextInstalled, previousState, nextState, verifiedById = new Map()) {
|
|
235
|
+
const transition = { writes: new Map(), removes: new Set() };
|
|
236
|
+
mergeTransition(transition, planFileTransition(root, extensionId, previousInstalled, nextInstalled, verifiedById));
|
|
237
|
+
mergeTransition(transition, planTextTransition(root, extensionId, previousInstalled, nextInstalled, verifiedById));
|
|
238
|
+
mergeTransition(transition, planJsonTransition(root, extensionId, previousInstalled, nextInstalled));
|
|
239
|
+
mergeTransition(transition, planLegacyArtifactTransition(root, extensionId, previousInstalled, nextInstalled, previousState, nextState));
|
|
240
|
+
for (const file of transition.writes.keys()) transition.removes.delete(file);
|
|
241
|
+
return transition;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function verifyArtifactTransition(transition) {
|
|
245
|
+
for (const [file, content] of transition.writes) {
|
|
246
|
+
if (!fs.existsSync(file) || !fs.readFileSync(file).equals(Buffer.isBuffer(content) ? content : Buffer.from(content))) {
|
|
247
|
+
throw artifactError("EXTENSION_POSTCONDITION_FAILED", `Extension target could not be verified: ${file}`, { file });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const file of transition.removes) if (fs.existsSync(file)) throw artifactError("EXTENSION_POSTCONDITION_FAILED", `Obsolete extension target was not removed: ${file}`, { file });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function installedArtifactsCurrent(root, extensionId, installed, state) {
|
|
254
|
+
try {
|
|
255
|
+
for (const artifact of artifactsOfKind(installed, "file")) {
|
|
256
|
+
const file = path.join(root, ...artifact.target.split("/"));
|
|
257
|
+
if (!fs.existsSync(file) || sha256(fs.readFileSync(file)) !== artifact.installedSha256) return false;
|
|
258
|
+
}
|
|
259
|
+
for (const artifact of artifactsOfKind(installed, "directory")) {
|
|
260
|
+
const directory = path.join(root, ...artifact.target.split("/"));
|
|
261
|
+
if (!fs.existsSync(directory) || directoryDigest(directory) !== artifact.installedSha256) return false;
|
|
262
|
+
}
|
|
263
|
+
for (const artifact of artifactsOfKind(installed, "text-block")) {
|
|
264
|
+
const file = path.join(root, ...artifact.target.split("/"));
|
|
265
|
+
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
266
|
+
const found = findTextBlock(content, extensionId, artifact.id);
|
|
267
|
+
if (!found || sha256(Buffer.from(found.fragment)) !== artifact.installedSha256) return false;
|
|
268
|
+
}
|
|
269
|
+
for (const artifact of artifactsOfKind(installed, "json-member")) {
|
|
270
|
+
const document = readJsonDocument(path.join(root, ...artifact.target.split("/")));
|
|
271
|
+
const segments = decodePointer(artifact.selector);
|
|
272
|
+
let current = document;
|
|
273
|
+
for (const segment of segments.slice(0, -1)) current = current?.[segment];
|
|
274
|
+
if (!current || typeof current !== "object" || canonicalJson(current[segments.at(-1)]) !== canonicalJson(artifact.payload)) return false;
|
|
275
|
+
}
|
|
276
|
+
return legacyArtifactsCurrent(root, extensionId, installed, state);
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function directoryArtifacts(installed) {
|
|
283
|
+
return artifactsOfKind(installed, "directory");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function removeEmptyParents(root, file) {
|
|
287
|
+
const stop = path.resolve(root);
|
|
288
|
+
let directory = path.dirname(file);
|
|
289
|
+
while (directory !== stop && directory.startsWith(`${stop}${path.sep}`)) {
|
|
290
|
+
if (!fs.existsSync(directory) || fs.readdirSync(directory).length > 0) break;
|
|
291
|
+
fs.rmdirSync(directory);
|
|
292
|
+
directory = path.dirname(directory);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
ARTIFACT_KINDS,
|
|
298
|
+
CODEX_CONFIG_TARGET,
|
|
299
|
+
CODEX_HOOKS_TARGET,
|
|
300
|
+
LEGACY_ARTIFACT_KINDS,
|
|
301
|
+
canonicalize,
|
|
302
|
+
composeHookContent,
|
|
303
|
+
directoryArtifacts,
|
|
304
|
+
installedArtifactsCurrent,
|
|
305
|
+
installedRecord,
|
|
306
|
+
planArtifactTransition,
|
|
307
|
+
removeEmptyParents,
|
|
308
|
+
targetForArtifact,
|
|
309
|
+
verifyArtifactTransition,
|
|
310
|
+
};
|