code-workspace-zhuiyi 1.0.0-beta.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -13
- package/README.zh-CN.md +2 -13
- package/artifacts/manifest.json +1 -1
- package/artifacts/templates/extension-init/README.md +0 -0
- package/artifacts/templates/extension-init/extension/init.js +4 -0
- package/artifacts/templates/extension-init/gitignore +1 -0
- package/docs/extension-development/extension-development-guide.zh-CN.md +44 -9
- package/package.json +1 -1
- package/src/cli/commands/extension.js +73 -3
- package/src/cli/commands/init.js +20 -1
- package/src/cli/registry.js +6 -0
- package/src/core/extension-package.js +205 -0
- package/src/core/extension-scaffold.js +212 -0
- package/src/core/extensions.js +5 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Code Workspace
|
|
2
2
|
|
|
3
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
3
5
|
Code Workspace is a local multi-project registry and safety layer for Claude Code and Codex. It manages workspace identity, project locations and branches, agent instructions, writable-root permissions, validation, and optional monitoring.
|
|
4
6
|
|
|
5
7
|
## Requirements
|
|
@@ -13,11 +15,8 @@ Code Workspace is a local multi-project registry and safety layer for Claude Cod
|
|
|
13
15
|
npm install -g code-workspace-zhuiyi@latest
|
|
14
16
|
```
|
|
15
17
|
|
|
16
|
-
The package provides the `code-workspace` command and the shorter `codew` alias. The legacy `code-w` alias remains available for compatibility.
|
|
17
|
-
|
|
18
18
|
## Initialize
|
|
19
19
|
|
|
20
|
-
|
|
21
20
|
Interactive initialization:
|
|
22
21
|
|
|
23
22
|
```bash
|
|
@@ -226,16 +225,7 @@ codew language --json
|
|
|
226
225
|
|
|
227
226
|
`update` refreshes only Workspace-owned managed assets and never changes Agent directory authorization. Unknown local edits stop the batch before writes; review them or pass `--force` explicitly.
|
|
228
227
|
|
|
229
|
-
##
|
|
230
|
-
|
|
231
|
-
Monitor is an ordinary Nexus extension. Install it into a workspace and run its dashboard through the generic extension runtime:
|
|
232
|
-
|
|
233
|
-
```bash
|
|
234
|
-
codew extension install monitor --yes
|
|
235
|
-
codew ext monitor serve --port 3211
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
The monitor binds to loopback, combines events from multiple initialized workspaces, and keeps hook reporting (`codew ext monitor report`) failure-open. Review and trust project hooks in Codex before relying on reports.
|
|
228
|
+
## Completion
|
|
239
229
|
|
|
240
230
|
```bash
|
|
241
231
|
codew completion --shell zsh
|
package/README.zh-CN.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Code Workspace
|
|
2
2
|
|
|
3
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
3
5
|
Code Workspace 是面向 Claude Code 与 Codex 的本地多项目注册表和安全边界层,负责工作区身份、项目路径与分支、Agent 指令、可写目录权限、校验以及可选监控。
|
|
4
6
|
|
|
5
7
|
## 环境要求
|
|
@@ -13,8 +15,6 @@ Code Workspace 是面向 Claude Code 与 Codex 的本地多项目注册表和安
|
|
|
13
15
|
npm install -g code-workspace-zhuiyi@latest
|
|
14
16
|
```
|
|
15
17
|
|
|
16
|
-
软件包提供 `code-workspace` 命令及短别名 `codew`。为保持兼容,旧别名 `code-w` 仍然可用。
|
|
17
|
-
|
|
18
18
|
## 初始化
|
|
19
19
|
|
|
20
20
|
交互式初始化:
|
|
@@ -223,17 +223,6 @@ codew language --json
|
|
|
223
223
|
|
|
224
224
|
`update` 只刷新 Workspace 自有托管资产,绝不会修改 Agent 目录授权。遇到未知本地修改时,会在任何写入前终止批次;请先审查修改,或显式传入 `--force`。
|
|
225
225
|
|
|
226
|
-
## 监控
|
|
227
|
-
|
|
228
|
-
监控以 Nexus 中的 `monitor` 扩展提供。先在 Workspace 中安装扩展,再通过通用扩展运行时启动面板:
|
|
229
|
-
|
|
230
|
-
```bash
|
|
231
|
-
codew extension install monitor --yes
|
|
232
|
-
codew ext monitor serve --port 3211
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
监控服务仅绑定 loopback,可汇总多个已初始化工作区的事件;hook 上报(`codew ext monitor report`)失败不会阻断 Agent。依赖监控前,请在 Codex 中检查并信任项目 hook。
|
|
236
|
-
|
|
237
226
|
## 命令补全
|
|
238
227
|
|
|
239
228
|
```bash
|
package/artifacts/manifest.json
CHANGED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/dist
|
|
@@ -35,7 +35,27 @@ Host 负责确认、校验、事务提交、状态记录、升级和卸载。
|
|
|
35
35
|
|
|
36
36
|
## 2. 包目录
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
系统扩展使用固定版本目录;普通扩展建议使用 `extension init` 创建最小 package 空壳,再按 Extension Spec 添加能力并发布到 Nexus:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
codew extension init ./example-extension --yes
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
开发 package 的目录结构为:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
example-extension/
|
|
48
|
+
├── .gitignore
|
|
49
|
+
├── README.md
|
|
50
|
+
├── extension/
|
|
51
|
+
│ ├── manifest.json
|
|
52
|
+
│ └── init.js
|
|
53
|
+
└── package.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
初始化产物中的静态文件模板位于仓库的 `artifacts/templates/extension-init/`。后续调整 README、`.gitignore` 或 `extension/` 下的默认文件时,直接修改该模板目录即可;其中 `gitignore` 会在生成时映射为 `.gitignore`,以避免 npm 发布包排除点文件。`extension init` 只负责注入元数据、生成摘要并写入 package envelope。
|
|
57
|
+
|
|
58
|
+
系统扩展仍使用固定版本目录:
|
|
39
59
|
|
|
40
60
|
```text
|
|
41
61
|
extensions/<extension-id>/<version>/
|
|
@@ -45,7 +65,7 @@ extensions/<extension-id>/<version>/
|
|
|
45
65
|
└── SKILL.md
|
|
46
66
|
```
|
|
47
67
|
|
|
48
|
-
|
|
68
|
+
公开 CLI 的 `extension pack <source>` 要求 `<source>` 直接指向包含 `package.json` 和 `extension/` 的 package 根目录。空壳尚未声明 `outputs`、`hooks` 或 `runtime` 时不能打包。
|
|
49
69
|
|
|
50
70
|
命名要求:
|
|
51
71
|
|
|
@@ -53,11 +73,18 @@ extensions/<extension-id>/<version>/
|
|
|
53
73
|
- `version` 必须是完整 SemVer,例如 `1.0.0`;
|
|
54
74
|
- 已发布版本目录不可修改;新实现创建新的版本目录。
|
|
55
75
|
|
|
56
|
-
|
|
76
|
+
根目录 `package.json` 是固定格式的 transport envelope,不要添加 `dependencies`、`scripts` 或其他 npm lifecycle 字段。`extension pack` 只校验并打包这个 envelope,不会自动生成或修改它。
|
|
77
|
+
|
|
78
|
+
`extension init` 只询问扩展 id、显示名称、描述和版本,不预设扩展是 Skill、MCP、Hook 还是 Runtime。完成 manifest 能力声明后,再运行:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
codew extension digest update ./example-extension --yes
|
|
82
|
+
codew extension pack ./example-extension --output ./dist/extensions --json
|
|
83
|
+
```
|
|
57
84
|
|
|
58
85
|
## 3. manifest
|
|
59
86
|
|
|
60
|
-
Spec v1 使用 manifest schema v3
|
|
87
|
+
Spec v1 使用 manifest schema v3。下面是声明了一个 file output 的最小完整示例;`extension init` 生成的空壳会在用户添加能力前缺少 `outputs`、`hooks` 或 `runtime`:
|
|
61
88
|
|
|
62
89
|
```json
|
|
63
90
|
{
|
|
@@ -89,7 +116,13 @@ Spec v1 使用 manifest schema v3。最小示例:
|
|
|
89
116
|
node -e "process.stdout.write(require('node:crypto').createHash('sha256').update(require('node:fs').readFileSync('extensions/example-extension/1.0.0/init.js')).digest('hex'))"
|
|
90
117
|
```
|
|
91
118
|
|
|
92
|
-
每次修改 `init.js`
|
|
119
|
+
每次修改 `init.js` 或 `extension/` 下其他文件后,使用 CLI 同步摘要:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
codew extension digest update ./example-extension --yes
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
该命令会先更新 `extension/manifest.json.entrySha256`,再更新 `package.json.codeWorkspace.packageSha256`。
|
|
93
126
|
|
|
94
127
|
### 3.1 身份与展示
|
|
95
128
|
|
|
@@ -296,10 +329,10 @@ codew extension upgrade example-extension --yes
|
|
|
296
329
|
|
|
297
330
|
## 6. npm/Nexus 打包
|
|
298
331
|
|
|
299
|
-
|
|
332
|
+
打包单个 package 根目录:
|
|
300
333
|
|
|
301
334
|
```bash
|
|
302
|
-
code-workspace extension pack
|
|
335
|
+
code-workspace extension pack ./example-extension --output dist/extensions --json
|
|
303
336
|
```
|
|
304
337
|
|
|
305
338
|
输出目录缺失时会递归创建。目标文件已存在时命令拒绝覆盖;重复打包前使用新的输出目录,或清理上一次构建产物。
|
|
@@ -331,14 +364,16 @@ npm 身份固定映射为:
|
|
|
331
364
|
|
|
332
365
|
打包器会:
|
|
333
366
|
|
|
334
|
-
- 校验 manifest、入口摘要、目录类型和 Extension Spec;
|
|
335
|
-
-
|
|
367
|
+
- 校验 package envelope、manifest、入口摘要、目录类型和 Extension Spec;
|
|
368
|
+
- 原样保留已验证的 `package.json`,不生成或修改 envelope;
|
|
336
369
|
- 只归档普通文件;
|
|
337
370
|
- 拒绝符号链接、硬链接、特殊文件、路径逃逸和重复路径;
|
|
338
371
|
- 限制最多 1024 个文件、单文件 2 MiB、载荷总计 16 MiB、envelope 64 KiB;
|
|
339
372
|
- 重新读取 tarball 并验证 envelope、manifest、入口和 package digest;
|
|
340
373
|
- 使用同目录临时文件并原子提交输出。
|
|
341
374
|
|
|
375
|
+
开发 package 的 README 和其他根目录文件不会进入 tarball;最终内容严格由 `package.json.files` 中的 `extension` 决定。
|
|
376
|
+
|
|
342
377
|
成功 JSON 返回 npm name、扩展身份、tarball 绝对路径、tarball integrity、manifest/entry/package digest 和文件清单。
|
|
343
378
|
|
|
344
379
|
## 7. 发布边界
|
package/package.json
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
1
2
|
const { LOCAL_DIRECTORY, loadState } = require("../../core/config");
|
|
2
3
|
const { WorkspaceError } = require("../../core/errors");
|
|
3
4
|
const {
|
|
@@ -13,7 +14,8 @@ const {
|
|
|
13
14
|
const { loadInitManifest } = require("../../core/init");
|
|
14
15
|
const { acquireInitLock } = require("../../core/init-lock");
|
|
15
16
|
const { defaultExtensionStoreRoot } = require("../../core/extension-store");
|
|
16
|
-
const {
|
|
17
|
+
const { packExtensionSourceToDirectory } = require("../../core/extension-package");
|
|
18
|
+
const { digestUpdate, scaffoldExtensionPackage } = require("../../core/extension-scaffold");
|
|
17
19
|
const { resolveExtensionSettings } = require("../../core/extension-settings");
|
|
18
20
|
const { DEFAULT_NEXUS_REGISTRY } = require("../../core/nexus-extension-provider");
|
|
19
21
|
const {
|
|
@@ -159,6 +161,24 @@ function uninstallResultEntry(entry) {
|
|
|
159
161
|
};
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
function sameUninstallTarget(expected, current) {
|
|
165
|
+
return expected.id === current.id
|
|
166
|
+
&& expected.version === current.version
|
|
167
|
+
&& (expected.packageSha256 || null) === (current.packageSha256 || null)
|
|
168
|
+
&& JSON.stringify(expected.targets || []) === JSON.stringify(current.targets || []);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function refreshUninstallPlan(root, frozenPlan) {
|
|
172
|
+
const currentPlan = planExtensionUninstall(root, frozenPlan.id);
|
|
173
|
+
if (currentPlan.action !== frozenPlan.action || !sameUninstallTarget(frozenPlan, currentPlan)) {
|
|
174
|
+
throw new WorkspaceError("EXTENSION_STATE_CONFLICT", `Extension ${frozenPlan.id} changed after confirmation.`, {
|
|
175
|
+
extension: frozenPlan.id,
|
|
176
|
+
remediation: "Re-run extension search and review the current extension selection.",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return currentPlan;
|
|
180
|
+
}
|
|
181
|
+
|
|
162
182
|
function validateInstallOptions(invocation) {
|
|
163
183
|
if (invocation.options.version) {
|
|
164
184
|
if (invocation.args.length !== 1) {
|
|
@@ -382,7 +402,8 @@ async function executeExtensionSearch(invocation) {
|
|
|
382
402
|
const uninstallResults = [];
|
|
383
403
|
for (const plan of effectiveUninstallPlans) {
|
|
384
404
|
try {
|
|
385
|
-
|
|
405
|
+
const currentPlan = refreshUninstallPlan(invocation.root, plan);
|
|
406
|
+
uninstallResults.push({ ...applyExtensionUninstall(currentPlan, {
|
|
386
407
|
...(dependencies || {}),
|
|
387
408
|
extensionStoreRoot: dependencies.extensionStoreRoot || defaultExtensionStoreRoot(),
|
|
388
409
|
}), action: "uninstall" });
|
|
@@ -539,7 +560,7 @@ async function executeExtensionPack(invocation) {
|
|
|
539
560
|
const command = "extension.pack";
|
|
540
561
|
const dependencies = invocation.dependencies || {};
|
|
541
562
|
const settings = resolveExtensionSettings(dependencies);
|
|
542
|
-
const result = await
|
|
563
|
+
const result = await packExtensionSourceToDirectory(invocation.args[0], invocation.options.output, {
|
|
543
564
|
...dependencies,
|
|
544
565
|
scope: dependencies.scope || settings.values.scope,
|
|
545
566
|
});
|
|
@@ -550,9 +571,56 @@ async function executeExtensionPack(invocation) {
|
|
|
550
571
|
);
|
|
551
572
|
}
|
|
552
573
|
|
|
574
|
+
async function collectExtensionInitOptions(invocation, target) {
|
|
575
|
+
const options = { ...invocation.options };
|
|
576
|
+
const interactive = !options.json && options.yes !== true && process.stdin.isTTY && process.stdout.isTTY;
|
|
577
|
+
if (!interactive) return options;
|
|
578
|
+
const ui = await createInteractiveUi({
|
|
579
|
+
cancelCode: "EXTENSION_INIT_CANCELLED",
|
|
580
|
+
cancelMessage: "Extension initialization cancelled. No changes were made.",
|
|
581
|
+
});
|
|
582
|
+
const defaultId = path.basename(path.resolve(target)).toLowerCase();
|
|
583
|
+
ui.intro("Create Code Workspace extension package");
|
|
584
|
+
if (options.id === undefined) options.id = await ui.text("Extension id", defaultId);
|
|
585
|
+
const defaultName = String(options.id || defaultId).split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
586
|
+
if (options.name === undefined) options.name = await ui.text("Extension name", defaultName);
|
|
587
|
+
if (options.description === undefined) options.description = await ui.text("Description", "Code Workspace extension.");
|
|
588
|
+
if (options.version === undefined) options.version = await ui.text("Version", "0.1.0");
|
|
589
|
+
ui.close("Extension metadata ready.");
|
|
590
|
+
return options;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function executeExtensionInit(invocation) {
|
|
594
|
+
const command = "extension.init";
|
|
595
|
+
const target = invocation.args[0] || ".";
|
|
596
|
+
const options = await collectExtensionInitOptions(invocation, target);
|
|
597
|
+
const pathText = path.resolve(target);
|
|
598
|
+
const planText = `Create extension package shell in ${pathText}:\n CREATE package.json\n CREATE extension/manifest.json\n CREATE extension/init.js\nContinue?`;
|
|
599
|
+
if (!(await confirm(planText, options))) throw new WorkspaceError("CLI_CANCELLED", "Extension package initialization cancelled.");
|
|
600
|
+
const result = scaffoldExtensionPackage(target, options);
|
|
601
|
+
const text = result.action === "skip"
|
|
602
|
+
? `Extension package is already current: ${result.path}.`
|
|
603
|
+
: `Created extension package shell in ${result.path}.\nNext: add outputs, hooks, or runtime to extension/manifest.json.`;
|
|
604
|
+
return success(command, result, text);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function executeExtensionDigestUpdate(invocation) {
|
|
608
|
+
const command = "extension.digest.update";
|
|
609
|
+
const target = invocation.args[0] || ".";
|
|
610
|
+
if (!(await confirm(`Update extension digests in ${path.resolve(target)}?`, invocation.options))) {
|
|
611
|
+
throw new WorkspaceError("CLI_CANCELLED", "Extension digest update cancelled.");
|
|
612
|
+
}
|
|
613
|
+
const result = digestUpdate(target);
|
|
614
|
+
return success(command, result, result.action === "skip"
|
|
615
|
+
? `Extension digests are already current: ${result.path}.`
|
|
616
|
+
: `Updated extension digests in ${result.path}.`);
|
|
617
|
+
}
|
|
618
|
+
|
|
553
619
|
async function executeExtension(invocation) {
|
|
554
620
|
const command = invocation.definition.path.join(".");
|
|
555
621
|
if (command === "extension.pack") return await executeExtensionPack(invocation);
|
|
622
|
+
if (command === "extension.init") return await executeExtensionInit(invocation);
|
|
623
|
+
if (command === "extension.digest.update") return await executeExtensionDigestUpdate(invocation);
|
|
556
624
|
if (command === "extension.search") {
|
|
557
625
|
const dependencies = invocation.dependencies || {};
|
|
558
626
|
const interactive = dependencies.interactive ?? (!invocation.options.json && invocation.options.yes !== true && process.stdin.isTTY && process.stdout.isTTY);
|
|
@@ -581,6 +649,8 @@ module.exports = {
|
|
|
581
649
|
executeExtension,
|
|
582
650
|
executeExtensionInstall,
|
|
583
651
|
executeExtensionPack,
|
|
652
|
+
executeExtensionInit,
|
|
653
|
+
executeExtensionDigestUpdate,
|
|
584
654
|
executeExtensionSearch,
|
|
585
655
|
executeExtensionInfo,
|
|
586
656
|
executeExtensionUninstall,
|
package/src/cli/commands/init.js
CHANGED
|
@@ -44,6 +44,24 @@ function stateWithPlans(state, plans) {
|
|
|
44
44
|
return next;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function sameUninstallTarget(expected, current) {
|
|
48
|
+
return expected.id === current.id
|
|
49
|
+
&& expected.version === current.version
|
|
50
|
+
&& (expected.packageSha256 || null) === (current.packageSha256 || null)
|
|
51
|
+
&& JSON.stringify(expected.targets || []) === JSON.stringify(current.targets || []);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function refreshUninstallPlan(root, frozenPlan) {
|
|
55
|
+
const currentPlan = planExtensionUninstall(root, frozenPlan.id);
|
|
56
|
+
if (currentPlan.action !== frozenPlan.action || !sameUninstallTarget(frozenPlan, currentPlan)) {
|
|
57
|
+
throw new WorkspaceError("EXTENSION_STATE_CONFLICT", `Extension ${frozenPlan.id} changed after confirmation.`, {
|
|
58
|
+
extension: frozenPlan.id,
|
|
59
|
+
remediation: "Re-run init and review the current extension selection.",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return currentPlan;
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
function migrationData(plan) {
|
|
48
66
|
if (!plan) return null;
|
|
49
67
|
return {
|
|
@@ -258,8 +276,9 @@ async function executeInitUnlocked(invocation, root) {
|
|
|
258
276
|
const ordinaryRemovalResults = [];
|
|
259
277
|
const ordinaryRemovalDiagnostics = [];
|
|
260
278
|
for (const removalPlan of ordinaryRemovalPlans) {
|
|
261
|
-
const
|
|
279
|
+
const frozenPlan = typeof removalPlan === "string" ? planExtensionUninstall(root, removalPlan) : removalPlan;
|
|
262
280
|
try {
|
|
281
|
+
const planToApply = refreshUninstallPlan(root, frozenPlan);
|
|
263
282
|
const removal = applyExtensionUninstall(planToApply, {
|
|
264
283
|
extensionStoreRoot: options.extensionStoreRoot || dependencies.extensionStoreRoot || defaultExtensionStoreRoot(),
|
|
265
284
|
});
|
package/src/cli/registry.js
CHANGED
|
@@ -23,6 +23,8 @@ const COMMAND_SUMMARIES = {
|
|
|
23
23
|
"extension install": "Install one or more extensions into the Workspace",
|
|
24
24
|
"extension uninstall": "Uninstall one ordinary extension from the Workspace",
|
|
25
25
|
"extension pack": "Create a verified npm tarball from an extension package",
|
|
26
|
+
"extension init": "Create an extension package shell",
|
|
27
|
+
"extension digest update": "Update extension entry and package digests",
|
|
26
28
|
"extension search": "Search, install, or update extensions (TTY); read-only results in JSON/non-TTY",
|
|
27
29
|
"extension info": "Show extension metadata from the configured Registry",
|
|
28
30
|
"extension upgrade": "Upgrade installed ordinary extensions",
|
|
@@ -70,6 +72,10 @@ const COMMANDS = [
|
|
|
70
72
|
} },
|
|
71
73
|
{ path: ["extension", "uninstall"], args: [{ name: "name", required: true }], workspace: "required", config: [], interaction: "required", effects: "planned-write", options: { yes: { type: "boolean" } } },
|
|
72
74
|
{ path: ["extension", "pack"], args: [{ name: "source", required: true }], workspace: "none", config: [], interaction: "never", effects: "planned-write", options: { output: { type: "string", required: true } } },
|
|
75
|
+
{ path: ["extension", "init"], args: [{ name: "path", required: false }], workspace: "none", config: [], interaction: "optional", effects: "planned-write", options: {
|
|
76
|
+
id: { type: "string" }, name: { type: "string" }, description: { type: "string" }, version: { type: "string" }, yes: { type: "boolean" },
|
|
77
|
+
} },
|
|
78
|
+
{ path: ["extension", "digest", "update"], args: [{ name: "path", required: false }], workspace: "none", config: [], interaction: "optional", effects: "planned-write", options: { yes: { type: "boolean" } } },
|
|
73
79
|
{ path: ["extension", "search"], args: [{ name: "query", required: false }], workspace: "required", config: ["identity", "language"], interaction: "required", effects: "planned-write", options: { yes: { type: "boolean" } } },
|
|
74
80
|
{ path: ["extension", "info"], args: [{ name: "name", required: true }], workspace: "none", config: [], interaction: "never", effects: "external", options: {} },
|
|
75
81
|
{ path: ["extension", "upgrade"], args: [{ name: "name", required: true, variadic: true }], workspace: "required", config: ["identity", "language"], interaction: "required", effects: "planned-write", options: { yes: { type: "boolean" }, offline: { type: "boolean" } } },
|
|
@@ -590,6 +590,209 @@ async function hashFile(file, algorithm, encoding) {
|
|
|
590
590
|
return hash.digest(encoding);
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
+
function readPackageJson(file) {
|
|
594
|
+
let value;
|
|
595
|
+
try {
|
|
596
|
+
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
597
|
+
} catch (error) {
|
|
598
|
+
throw packageError("EXTENSION_PACKAGE_JSON_INVALID", `Cannot parse package.json: ${error.message}`, { path: file });
|
|
599
|
+
}
|
|
600
|
+
return value;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function inspectExtensionSourcePackage(source, options = {}) {
|
|
604
|
+
const packageRoot = path.resolve(source);
|
|
605
|
+
let stat;
|
|
606
|
+
try {
|
|
607
|
+
stat = fs.lstatSync(packageRoot);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
throw packageError("EXTENSION_PACKAGE_MISSING", `Extension package directory is missing: ${packageRoot}`, {
|
|
610
|
+
path: packageRoot,
|
|
611
|
+
cause: error.code,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
615
|
+
throw packageError("EXTENSION_PACKAGE_INVALID", `Extension package must be a regular directory: ${packageRoot}`, { path: packageRoot });
|
|
616
|
+
}
|
|
617
|
+
const packageJsonFile = path.join(packageRoot, "package.json");
|
|
618
|
+
const extensionRoot = path.join(packageRoot, EXTENSION_PACKAGE_ROOT);
|
|
619
|
+
let packageJsonStat;
|
|
620
|
+
try { packageJsonStat = fs.lstatSync(packageJsonFile); } catch (error) {
|
|
621
|
+
throw packageError("EXTENSION_NPM_ENVELOPE_MISSING", `Extension package is missing package.json: ${packageJsonFile}`, { path: packageJsonFile, cause: error.code });
|
|
622
|
+
}
|
|
623
|
+
if (!packageJsonStat.isFile() || packageJsonStat.isSymbolicLink()) {
|
|
624
|
+
throw packageError("EXTENSION_NPM_ENVELOPE_INVALID", `package.json must be a regular file: ${packageJsonFile}`, { path: packageJsonFile });
|
|
625
|
+
}
|
|
626
|
+
let extensionStat;
|
|
627
|
+
try { extensionStat = fs.lstatSync(extensionRoot); } catch (error) {
|
|
628
|
+
throw packageError("EXTENSION_PACKAGE_LAYOUT_INVALID", `Extension package is missing extension/: ${extensionRoot}`, { path: extensionRoot, cause: error.code });
|
|
629
|
+
}
|
|
630
|
+
if (!extensionStat.isDirectory() || extensionStat.isSymbolicLink()) {
|
|
631
|
+
throw packageError("EXTENSION_PACKAGE_LAYOUT_INVALID", `Extension package root must be a regular directory: ${extensionRoot}`, { path: extensionRoot });
|
|
632
|
+
}
|
|
633
|
+
const packageJsonBytes = fs.readFileSync(packageJsonFile);
|
|
634
|
+
const rawEnvelope = readPackageJson(packageJsonFile);
|
|
635
|
+
const envelope = validateExtensionTransportEnvelope(rawEnvelope, {
|
|
636
|
+
...(options.scope ? { expectedScope: options.scope } : {}),
|
|
637
|
+
});
|
|
638
|
+
const inspected = inspectExtensionPackageDirectory(extensionRoot, {
|
|
639
|
+
expectedId: envelope.codeWorkspace.extensionId,
|
|
640
|
+
expectedVersion: envelope.version,
|
|
641
|
+
expectedExtensionSpecVersion: envelope.codeWorkspace.extensionSpecVersion,
|
|
642
|
+
...(options.supportedExtensionSpecVersions ? { supportedExtensionSpecVersions: options.supportedExtensionSpecVersions } : {}),
|
|
643
|
+
...(options.protectedTargets ? { protectedTargets: options.protectedTargets } : {}),
|
|
644
|
+
});
|
|
645
|
+
try {
|
|
646
|
+
validateExtensionTransportEnvelope(rawEnvelope, {
|
|
647
|
+
expectedId: inspected.id,
|
|
648
|
+
expectedVersion: inspected.version,
|
|
649
|
+
expectedExtensionSpecVersion: inspected.extensionSpecVersion,
|
|
650
|
+
expectedPackageSha256: inspected.packageSha256,
|
|
651
|
+
...(options.scope ? { expectedScope: options.scope } : {}),
|
|
652
|
+
});
|
|
653
|
+
} catch (error) {
|
|
654
|
+
if (error.code === "EXTENSION_NPM_PACKAGE_DIGEST_MISMATCH") {
|
|
655
|
+
error.details = { ...(error.details || {}), remediation: "Run codew extension digest update <package-path> before packing." };
|
|
656
|
+
}
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
659
|
+
if (envelope.description !== inspected.manifest.description) {
|
|
660
|
+
throw packageError("EXTENSION_NPM_IDENTITY_MISMATCH", "npm transport description does not match the Extension manifest", {
|
|
661
|
+
field: "description",
|
|
662
|
+
expected: inspected.manifest.description,
|
|
663
|
+
actual: envelope.description,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return Object.freeze({
|
|
667
|
+
...inspected,
|
|
668
|
+
packageRoot,
|
|
669
|
+
extensionRoot,
|
|
670
|
+
packageJsonFile,
|
|
671
|
+
packageJsonBytes,
|
|
672
|
+
envelope,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function assertSourcePackageUnchanged(source, inspected, files, options = {}) {
|
|
677
|
+
let current;
|
|
678
|
+
try {
|
|
679
|
+
current = inspectExtensionSourcePackage(source, options);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
throw packageError("EXTENSION_PACKAGE_CHANGED", `Extension package changed while packing: ${inspected.id}@${inspected.version}`, {
|
|
682
|
+
extension: inspected.id,
|
|
683
|
+
version: inspected.version,
|
|
684
|
+
cause: error.code,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
const currentFiles = collectPackageFiles(current.extensionRoot, options.limits);
|
|
688
|
+
const digestMatches = current.packageJsonBytes.equals(inspected.packageJsonBytes)
|
|
689
|
+
&& current.packageSha256 === inspected.packageSha256
|
|
690
|
+
&& current.manifestSha256 === inspected.manifestSha256
|
|
691
|
+
&& current.entrySha256 === inspected.entrySha256;
|
|
692
|
+
const filesMatch = currentFiles.length === files.length && currentFiles.every((file, index) =>
|
|
693
|
+
file.relative === files[index].relative && file.size === files[index].size && file.mode === files[index].mode && file.sha256 === files[index].sha256
|
|
694
|
+
);
|
|
695
|
+
if (!digestMatches || !filesMatch) {
|
|
696
|
+
throw packageError("EXTENSION_PACKAGE_CHANGED", `Extension package changed while packing: ${inspected.id}@${inspected.version}`, {
|
|
697
|
+
extension: inspected.id,
|
|
698
|
+
version: inspected.version,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function packExtensionSourceToDirectory(source, outputDirectory, options = {}) {
|
|
704
|
+
// Keep the repository's built-in legacy extension layout packable while the
|
|
705
|
+
// public authoring workflow uses a package-root envelope.
|
|
706
|
+
if (!fs.existsSync(path.join(path.resolve(source), "package.json"))) {
|
|
707
|
+
return packExtensionToDirectory(source, outputDirectory, options);
|
|
708
|
+
}
|
|
709
|
+
const limits = normalizeLimits(options.limits);
|
|
710
|
+
const outputRoot = path.resolve(outputDirectory);
|
|
711
|
+
const inspected = inspectExtensionSourcePackage(source, {
|
|
712
|
+
...(options.scope ? { scope: options.scope } : {}),
|
|
713
|
+
...(options.supportedExtensionSpecVersions ? { supportedExtensionSpecVersions: options.supportedExtensionSpecVersions } : {}),
|
|
714
|
+
...(options.protectedTargets ? { protectedTargets: options.protectedTargets } : {}),
|
|
715
|
+
});
|
|
716
|
+
const files = collectPackageFiles(inspected.extensionRoot, limits);
|
|
717
|
+
try {
|
|
718
|
+
fs.mkdirSync(outputRoot, { recursive: true });
|
|
719
|
+
} catch (error) {
|
|
720
|
+
throw packageError("EXTENSION_PACK_OUTPUT_CREATE_FAILED", `Cannot create output directory: ${outputRoot}`, { path: outputRoot, cause: error.code });
|
|
721
|
+
}
|
|
722
|
+
const outputStat = fs.lstatSync(outputRoot);
|
|
723
|
+
if (!outputStat.isDirectory() || outputStat.isSymbolicLink()) {
|
|
724
|
+
throw packageError("EXTENSION_PACK_OUTPUT_INVALID", `Output must be a regular directory: ${outputRoot}`, { path: outputRoot });
|
|
725
|
+
}
|
|
726
|
+
const filename = extensionNpmTarballFilename(inspected.id, inspected.version, options.scope);
|
|
727
|
+
const target = path.join(outputRoot, filename);
|
|
728
|
+
if (fs.existsSync(target)) {
|
|
729
|
+
throw packageError("EXTENSION_PACK_OUTPUT_EXISTS", `Extension package output already exists: ${target}`, {
|
|
730
|
+
path: target,
|
|
731
|
+
remediation: "Move or remove the existing tarball, or choose another output directory.",
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
const temporaryTarget = path.join(outputRoot, `.${filename}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
735
|
+
const fd = fs.openSync(temporaryTarget, "wx", 0o600);
|
|
736
|
+
fs.closeSync(fd);
|
|
737
|
+
let targetReserved = false;
|
|
738
|
+
try {
|
|
739
|
+
try {
|
|
740
|
+
await writeTransportTarball({
|
|
741
|
+
sourceRoot: inspected.extensionRoot,
|
|
742
|
+
files,
|
|
743
|
+
envelopeBytes: inspected.packageJsonBytes,
|
|
744
|
+
target: temporaryTarget,
|
|
745
|
+
limits,
|
|
746
|
+
});
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (error instanceof WorkspaceError) throw error;
|
|
749
|
+
throw packageError("EXTENSION_TARBALL_CREATE_FAILED", `Cannot create extension npm tarball: ${error.message}`, { path: target, cause: error.code });
|
|
750
|
+
}
|
|
751
|
+
assertSourcePackageUnchanged(source, inspected, files, { ...options, limits });
|
|
752
|
+
let verified;
|
|
753
|
+
try {
|
|
754
|
+
verified = await inspectExtensionTransportTarball(temporaryTarget, {
|
|
755
|
+
expectedId: inspected.id,
|
|
756
|
+
expectedVersion: inspected.version,
|
|
757
|
+
expectedExtensionSpecVersion: inspected.extensionSpecVersion,
|
|
758
|
+
expectedPackageSha256: inspected.packageSha256,
|
|
759
|
+
...(options.scope ? { expectedScope: options.scope } : {}),
|
|
760
|
+
expectedFiles: files,
|
|
761
|
+
limits,
|
|
762
|
+
});
|
|
763
|
+
} catch (error) {
|
|
764
|
+
if (error instanceof WorkspaceError) throw error;
|
|
765
|
+
throw packageError("EXTENSION_TARBALL_VERIFY_FAILED", `Cannot verify extension npm tarball: ${error.message}`, { path: target, cause: error.code });
|
|
766
|
+
}
|
|
767
|
+
if (!verified || verified.packageSha256 !== inspected.packageSha256 || verified.manifestSha256 !== inspected.manifestSha256 || verified.entrySha256 !== inspected.entrySha256) {
|
|
768
|
+
throw packageError("EXTENSION_TARBALL_VERIFY_FAILED", `Extension npm tarball verification failed: ${inspected.id}@${inspected.version}`, { extension: inspected.id, version: inspected.version });
|
|
769
|
+
}
|
|
770
|
+
const tarballBytes = fs.statSync(temporaryTarget).size;
|
|
771
|
+
const integrity = `sha512-${await hashFile(temporaryTarget, "sha512", "base64")}`;
|
|
772
|
+
const targetFd = fs.openSync(target, "wx", 0o600);
|
|
773
|
+
fs.closeSync(targetFd);
|
|
774
|
+
targetReserved = true;
|
|
775
|
+
fs.renameSync(temporaryTarget, target);
|
|
776
|
+
targetReserved = false;
|
|
777
|
+
return Object.freeze({
|
|
778
|
+
schemaVersion: 1,
|
|
779
|
+
extensionId: inspected.id,
|
|
780
|
+
version: inspected.version,
|
|
781
|
+
extensionSpecVersion: inspected.extensionSpecVersion,
|
|
782
|
+
npmName: inspected.envelope.name,
|
|
783
|
+
tarball: Object.freeze({ path: target, filename, bytes: tarballBytes, integrity, files: verified.files }),
|
|
784
|
+
manifestSha256: inspected.manifestSha256,
|
|
785
|
+
entrySha256: inspected.entrySha256,
|
|
786
|
+
packageSha256: inspected.packageSha256,
|
|
787
|
+
});
|
|
788
|
+
} finally {
|
|
789
|
+
try { fs.unlinkSync(temporaryTarget); } catch { /* cleanup is best-effort */ }
|
|
790
|
+
if (targetReserved) {
|
|
791
|
+
try { fs.unlinkSync(target); } catch { /* cleanup is best-effort */ }
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
593
796
|
async function packExtensionToDirectory(source, outputDirectory, options = {}) {
|
|
594
797
|
const limits = normalizeLimits(options.limits);
|
|
595
798
|
const outputRoot = path.resolve(outputDirectory);
|
|
@@ -733,6 +936,8 @@ module.exports = {
|
|
|
733
936
|
normalizeNpmScope,
|
|
734
937
|
extractExtensionTransportTarball,
|
|
735
938
|
inspectExtensionTransportTarball,
|
|
939
|
+
inspectExtensionSourcePackage,
|
|
940
|
+
packExtensionSourceToDirectory,
|
|
736
941
|
packExtensionToDirectory,
|
|
737
942
|
validateExtensionTransportEnvelope,
|
|
738
943
|
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
|
|
6
|
+
const { atomicWrite, sha256 } = require("./fs");
|
|
7
|
+
const { createFileTransaction } = require("./transaction");
|
|
8
|
+
const { directoryDigest } = require("./directory-digest");
|
|
9
|
+
const { EXTENSION_NAME_PATTERN, parseSemver, validateManifestEnvelope } = require("./extensions");
|
|
10
|
+
const { validateExtensionTransportEnvelope } = require("./extension-package");
|
|
11
|
+
const { WorkspaceError } = require("./errors");
|
|
12
|
+
|
|
13
|
+
function scaffoldError(code, message, details = {}) {
|
|
14
|
+
return new WorkspaceError(code, message, details);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function titleFromId(id) {
|
|
18
|
+
return id.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readJson(file, code) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw scaffoldError(code, `Cannot parse JSON file: ${file}`, { path: file, cause: error.message });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateScaffoldMetadata(options = {}, targetRoot) {
|
|
30
|
+
const derivedId = path.basename(targetRoot).toLowerCase();
|
|
31
|
+
const id = String(options.id || derivedId).trim();
|
|
32
|
+
if (!EXTENSION_NAME_PATTERN.test(id)) throw scaffoldError("EXTENSION_NAME_INVALID", `Invalid extension name: ${id || "<missing>"}`, { extension: id || null });
|
|
33
|
+
const name = String(options.name || titleFromId(id)).trim();
|
|
34
|
+
const description = String(options.description || "Code Workspace extension.").trim();
|
|
35
|
+
const version = parseSemver(options.version || "0.1.0").raw;
|
|
36
|
+
if (!name || [...name].length > 100) throw scaffoldError("EXTENSION_MANIFEST_INVALID", `Extension display name is invalid: ${id}`, { extension: id });
|
|
37
|
+
if (!description || [...description].length > 60 || /[\r\n]/.test(description)) throw scaffoldError("EXTENSION_MANIFEST_INVALID", `Extension description is invalid: ${id}`, { extension: id });
|
|
38
|
+
return Object.freeze({ id, name, description, version });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SCAFFOLD_TEMPLATE_ROOT = path.join(__dirname, "..", "..", "artifacts", "templates", "extension-init");
|
|
42
|
+
|
|
43
|
+
function readTemplateFiles(root = SCAFFOLD_TEMPLATE_ROOT, current = root, result = {}) {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = fs.readdirSync(current, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw scaffoldError("EXTENSION_TEMPLATE_INVALID", `Cannot read extension init template: ${root}`, { path: root, cause: error.code || error.message });
|
|
49
|
+
}
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const file = path.join(current, entry.name);
|
|
52
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
53
|
+
const stat = fs.lstatSync(file);
|
|
54
|
+
if (stat.isSymbolicLink()) throw scaffoldError("EXTENSION_TEMPLATE_INVALID", `Extension init template contains a symbolic link: ${relative}`, { path: relative });
|
|
55
|
+
if (stat.isDirectory()) readTemplateFiles(root, file, result);
|
|
56
|
+
else if (stat.isFile()) result[relative === "gitignore" ? ".gitignore" : relative] = fs.readFileSync(file);
|
|
57
|
+
else throw scaffoldError("EXTENSION_TEMPLATE_INVALID", `Extension init template contains a special file: ${relative}`, { path: relative });
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validateShellManifest(raw) {
|
|
63
|
+
const envelope = validateManifestEnvelope(raw);
|
|
64
|
+
if (raw.schemaVersion !== 3 || raw.experimental !== true || raw.entry !== "init.js") {
|
|
65
|
+
throw scaffoldError("EXTENSION_MANIFEST_INVALID", "Extension shell manifest must use schemaVersion 3, experimental true, and entry init.js", { extension: envelope.id });
|
|
66
|
+
}
|
|
67
|
+
if (!/^[a-f0-9]{64}$/.test(raw.entrySha256 || "")) throw scaffoldError("EXTENSION_MANIFEST_INVALID", `Extension ${envelope.id} entrySha256 is invalid`, { extension: envelope.id });
|
|
68
|
+
if (!Number.isInteger(raw.timeoutMs) || raw.timeoutMs < 1 || raw.timeoutMs > 300000) throw scaffoldError("EXTENSION_MANIFEST_INVALID", `Extension ${envelope.id} timeoutMs is invalid`, { extension: envelope.id });
|
|
69
|
+
return envelope;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readRelativeFiles(root, current = root, result = []) {
|
|
73
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
74
|
+
const file = path.join(current, entry.name);
|
|
75
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
76
|
+
const stat = fs.lstatSync(file);
|
|
77
|
+
if (stat.isSymbolicLink()) throw scaffoldError("EXTENSION_INIT_TARGET_EXISTS", `Scaffold target contains a symbolic link: ${relative}`, { path: relative });
|
|
78
|
+
if (stat.isDirectory()) readRelativeFiles(root, file, result);
|
|
79
|
+
else if (stat.isFile()) result.push(relative);
|
|
80
|
+
else throw scaffoldError("EXTENSION_INIT_TARGET_EXISTS", `Scaffold target contains a special file: ${relative}`, { path: relative });
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function filesMatch(root, files) {
|
|
86
|
+
let actual;
|
|
87
|
+
try { actual = readRelativeFiles(root); } catch { return false; }
|
|
88
|
+
const expected = Object.keys(files).sort();
|
|
89
|
+
if (actual.length !== expected.length || actual.some((entry, index) => entry !== expected[index])) return false;
|
|
90
|
+
return expected.every((relative) => Buffer.from(fs.readFileSync(path.join(root, ...relative.split("/")))).equals(files[relative]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function commitDirectory(temporary, target) {
|
|
94
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
95
|
+
if (!fs.existsSync(target)) {
|
|
96
|
+
fs.renameSync(temporary, target);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const backup = `${target}.${process.pid}.${crypto.randomUUID()}.old`;
|
|
100
|
+
let moved = false;
|
|
101
|
+
try {
|
|
102
|
+
fs.renameSync(target, backup);
|
|
103
|
+
moved = true;
|
|
104
|
+
fs.renameSync(temporary, target);
|
|
105
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
try { if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true }); } catch {}
|
|
108
|
+
try { if (moved && fs.existsSync(backup)) fs.renameSync(backup, target); } catch {}
|
|
109
|
+
throw scaffoldError("EXTENSION_INIT_COMMIT_FAILED", `Cannot commit extension package: ${target}`, { path: target, cause: error.code || error.message });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function shellEnvelope(metadata, packageSha256) {
|
|
114
|
+
return {
|
|
115
|
+
name: `@codew-ext/${metadata.id}`,
|
|
116
|
+
version: metadata.version,
|
|
117
|
+
description: metadata.description,
|
|
118
|
+
keywords: ["code-workspace-extension"],
|
|
119
|
+
codeWorkspace: { schemaVersion: 1, extensionId: metadata.id, extensionSpecVersion: 1, packageRoot: "extension", packageSha256 },
|
|
120
|
+
files: ["extension"],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function scaffoldExtensionPackage(target, options = {}) {
|
|
125
|
+
const targetRoot = path.resolve(target || ".");
|
|
126
|
+
const metadata = validateScaffoldMetadata(options, targetRoot);
|
|
127
|
+
const templateFiles = readTemplateFiles();
|
|
128
|
+
const entry = templateFiles["extension/init.js"];
|
|
129
|
+
if (!entry) throw scaffoldError("EXTENSION_TEMPLATE_INVALID", "Extension init template must contain extension/init.js", { path: "extension/init.js" });
|
|
130
|
+
const manifest = {
|
|
131
|
+
schemaVersion: 3,
|
|
132
|
+
extensionSpecVersion: 1,
|
|
133
|
+
experimental: true,
|
|
134
|
+
id: metadata.id,
|
|
135
|
+
name: metadata.name,
|
|
136
|
+
description: metadata.description,
|
|
137
|
+
version: metadata.version,
|
|
138
|
+
entry: "init.js",
|
|
139
|
+
entrySha256: sha256(Buffer.from(entry, "utf8")),
|
|
140
|
+
timeoutMs: 30000,
|
|
141
|
+
};
|
|
142
|
+
const files = {
|
|
143
|
+
...templateFiles,
|
|
144
|
+
"package.json": null,
|
|
145
|
+
"extension/manifest.json": Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
|
|
146
|
+
};
|
|
147
|
+
const temporary = fs.mkdtempSync(path.join(path.dirname(targetRoot), `.${path.basename(targetRoot)}-`));
|
|
148
|
+
try {
|
|
149
|
+
for (const [relative, content] of Object.entries(files)) {
|
|
150
|
+
if (!content) continue;
|
|
151
|
+
const file = path.join(temporary, ...relative.split("/"));
|
|
152
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
153
|
+
fs.writeFileSync(file, content, { mode: relative.endsWith(".js") ? 0o755 : 0o644 });
|
|
154
|
+
}
|
|
155
|
+
const packageSha256 = directoryDigest(path.join(temporary, "extension"));
|
|
156
|
+
const envelope = shellEnvelope(metadata, packageSha256);
|
|
157
|
+
validateExtensionTransportEnvelope(envelope, { expectedId: metadata.id, expectedVersion: metadata.version, expectedExtensionSpecVersion: 1, expectedPackageSha256: packageSha256 });
|
|
158
|
+
files["package.json"] = Buffer.from(`${JSON.stringify(envelope, null, 2)}\n`, "utf8");
|
|
159
|
+
fs.writeFileSync(path.join(temporary, "package.json"), files["package.json"]);
|
|
160
|
+
if (fs.existsSync(targetRoot)) {
|
|
161
|
+
const stat = fs.lstatSync(targetRoot);
|
|
162
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw scaffoldError("EXTENSION_INIT_TARGET_EXISTS", `Scaffold target is not a regular directory: ${targetRoot}`, { path: targetRoot });
|
|
163
|
+
if (filesMatch(targetRoot, files)) return Object.freeze({ action: "skip", reason: "already-current", path: targetRoot, extension: metadata, files: Object.keys(files).sort() });
|
|
164
|
+
if (fs.readdirSync(targetRoot).length > 0) throw scaffoldError("EXTENSION_INIT_TARGET_EXISTS", `Scaffold target is not empty: ${targetRoot}`, { path: targetRoot, remediation: "Choose another directory or remove its files before running extension init." });
|
|
165
|
+
}
|
|
166
|
+
commitDirectory(temporary, targetRoot);
|
|
167
|
+
return Object.freeze({ action: "created", path: targetRoot, extension: metadata, files: Object.keys(files).sort() });
|
|
168
|
+
} finally {
|
|
169
|
+
try { fs.rmSync(temporary, { recursive: true, force: true }); } catch {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function digestUpdate(target) {
|
|
174
|
+
const packageRoot = path.resolve(target || ".");
|
|
175
|
+
const packageJsonFile = path.join(packageRoot, "package.json");
|
|
176
|
+
const extensionRoot = path.join(packageRoot, "extension");
|
|
177
|
+
const manifestFile = path.join(extensionRoot, "manifest.json");
|
|
178
|
+
const rawPackage = readJson(packageJsonFile, "EXTENSION_NPM_ENVELOPE_INVALID");
|
|
179
|
+
const rawManifest = readJson(manifestFile, "EXTENSION_MANIFEST_PARSE_FAILED");
|
|
180
|
+
const manifest = validateShellManifest(rawManifest);
|
|
181
|
+
validateExtensionTransportEnvelope(rawPackage, { expectedId: manifest.id, expectedVersion: manifest.version, expectedExtensionSpecVersion: manifest.extensionSpecVersion });
|
|
182
|
+
if (rawPackage.description !== rawManifest.description) throw scaffoldError("EXTENSION_NPM_IDENTITY_MISMATCH", "npm transport description does not match the Extension manifest", { field: "description", expected: rawManifest.description, actual: rawPackage.description });
|
|
183
|
+
const entryFile = path.join(extensionRoot, ...rawManifest.entry.split("/"));
|
|
184
|
+
if (!fs.existsSync(entryFile)) throw scaffoldError("EXTENSION_ENTRY_MISSING", `Extension entry is missing: ${entryFile}`, { path: entryFile });
|
|
185
|
+
const nextManifest = { ...rawManifest, entrySha256: sha256(fs.readFileSync(entryFile)) };
|
|
186
|
+
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "code-workspace-digest-"));
|
|
187
|
+
try {
|
|
188
|
+
const tempExtension = path.join(temporary, "extension");
|
|
189
|
+
fs.cpSync(extensionRoot, tempExtension, { recursive: true, force: true });
|
|
190
|
+
fs.writeFileSync(path.join(tempExtension, "manifest.json"), `${JSON.stringify(nextManifest, null, 2)}\n`);
|
|
191
|
+
const packageSha256 = directoryDigest(tempExtension);
|
|
192
|
+
const nextPackage = { ...rawPackage, codeWorkspace: { ...rawPackage.codeWorkspace, packageSha256 } };
|
|
193
|
+
validateExtensionTransportEnvelope(nextPackage, { expectedId: manifest.id, expectedVersion: manifest.version, expectedExtensionSpecVersion: manifest.extensionSpecVersion, expectedPackageSha256: packageSha256 });
|
|
194
|
+
const nextManifestBytes = Buffer.from(`${JSON.stringify(nextManifest, null, 2)}\n`, "utf8");
|
|
195
|
+
const nextPackageBytes = Buffer.from(`${JSON.stringify(nextPackage, null, 2)}\n`, "utf8");
|
|
196
|
+
if (fs.readFileSync(packageJsonFile).equals(nextPackageBytes) && fs.readFileSync(manifestFile).equals(nextManifestBytes)) return Object.freeze({ action: "skip", reason: "already-current", path: packageRoot, extension: { id: manifest.id, version: manifest.version }, entrySha256: nextManifest.entrySha256, packageSha256, updatedFiles: [] });
|
|
197
|
+
const transaction = createFileTransaction([packageJsonFile, manifestFile]);
|
|
198
|
+
try {
|
|
199
|
+
atomicWrite(manifestFile, nextManifestBytes);
|
|
200
|
+
atomicWrite(packageJsonFile, nextPackageBytes);
|
|
201
|
+
transaction.commit();
|
|
202
|
+
} catch (error) {
|
|
203
|
+
transaction.rollback(error);
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
return Object.freeze({ action: "updated", path: packageRoot, extension: { id: manifest.id, version: manifest.version }, entrySha256: nextManifest.entrySha256, packageSha256, updatedFiles: ["extension/manifest.json", "package.json"] });
|
|
207
|
+
} finally {
|
|
208
|
+
try { fs.rmSync(temporary, { recursive: true, force: true }); } catch {}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { digestUpdate, scaffoldExtensionPackage, validateScaffoldMetadata };
|
package/src/core/extensions.js
CHANGED
|
@@ -307,7 +307,10 @@ function validateManifest(value, options = {}) {
|
|
|
307
307
|
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} outputs must be an array`, { extension: id });
|
|
308
308
|
}
|
|
309
309
|
if ((!Array.isArray(manifest.outputs) || manifest.outputs.length === 0) && hooks.length === 0 && !runtime) {
|
|
310
|
-
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} must declare at least one output, Hook or runtime`, {
|
|
310
|
+
throw extensionError("EXTENSION_MANIFEST_INVALID", `Extension ${id} must declare at least one output, Hook or runtime`, {
|
|
311
|
+
extension: id,
|
|
312
|
+
remediation: "Add outputs, hooks, or runtime to extension/manifest.json before packing.",
|
|
313
|
+
});
|
|
311
314
|
}
|
|
312
315
|
const ids = new Set();
|
|
313
316
|
const declaredArtifacts = [];
|
|
@@ -435,6 +438,7 @@ function inspectExtensionPackageDirectory(source, options = {}) {
|
|
|
435
438
|
version: manifest.version,
|
|
436
439
|
expectedSha256: manifest.entrySha256,
|
|
437
440
|
actualSha256: entrySha256,
|
|
441
|
+
remediation: "Run codew extension digest update <package-path> before packing.",
|
|
438
442
|
});
|
|
439
443
|
}
|
|
440
444
|
return Object.freeze({
|