coconet 0.4.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 +32 -0
- package/bin/coconet +91 -0
- package/cli.mjs +204 -0
- package/core.mjs +184 -0
- package/package.json +38 -0
- package/release-manifest.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# npm Launcher and Setup Wrapper
|
|
2
|
+
|
|
3
|
+
本目录发布未加 scope 的 `coconet` npm 包。默认入口是:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install --global coconet
|
|
7
|
+
coconet version
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
npm 在自己已经可执行的全局 bin 目录安装一个稳定 POSIX shell 启动器。启动器读取包版本和 `${XDG_DATA_HOME:-$HOME/.local/share}/coconet/current/manifest.json`:版本匹配时直接 `exec` 原生 Runtime;缺失或不匹配时调用该 npm 包的 `coconet-setup`,安装成功后在同一终端继续执行用户原参数。Node 只参与首次安装和升级,不成为日常父进程,也不需要再把 `~/.local/bin` 加入 `PATH`。
|
|
11
|
+
|
|
12
|
+
Bootstrap 在 npm 包与目标 Runtime 同版本时使用 `--no-bin-link`,只原子切换版本化 `current`,不会覆盖 npm 管理的 `coconet`。归档直装仍可创建 `${XDG_BIN_HOME:-$HOME/.local/bin}/coconet`。`coconet-setup` 是显式恢复、本地候选和高级参数入口,不是正常安装后的必做步骤。包同时暴露两个 bin,因此不把 `npx coconet` 的单-bin 推断作为正式交互或发布门禁;不要把本包加入业务仓库 dependency。
|
|
13
|
+
|
|
14
|
+
| 文件 | 职责 |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `bin/coconet` | 解析 npm 包与当前 Runtime 版本,按需启动 setup,并以原参数 `exec` 匹配 Runtime。 |
|
|
17
|
+
| `cli.mjs` | 下载、安全解压、临时目录清理与 Bootstrap 进程编排。 |
|
|
18
|
+
| `core.mjs` | 平台映射、制品命名、参数、checksum 和 archive path 契约。 |
|
|
19
|
+
| `cli.test.mjs` | 覆盖严格 umask、启动器匹配 / 缺失 / 升级 / 取消 / 失败状态,以及同一 Shell 的临时全局安装。 |
|
|
20
|
+
| `core.test.mjs` | 覆盖支持矩阵、参数映射、checksum、路径逃逸与本地文件校验。 |
|
|
21
|
+
| `package.json` / `package-lock.json` | npm 身份、双 bin、Node 基线、固定 tar 依赖与可重现安装。 |
|
|
22
|
+
| `release-manifest.json` | 固定 npm 同版本、`xfey/coconet` 精确 Tag 与四个平台 Client SHA-256;内部版本保持空映射。 |
|
|
23
|
+
|
|
24
|
+
包装层根据 macOS / Linux 与 `amd64` / `arm64` 选择制品,通过 HTTPS 下载或读取用户明确提供的本地 archive 与 checksum,验证 SHA-256,拒绝不安全 tar 条目和 mode,再执行同版本 Bootstrap。本地 artifact 同样经过文件类型、archive checksum、安全 tar 条目和 bundle Manifest 四层校验。包装层不会连接仓库或上传 Session。
|
|
25
|
+
|
|
26
|
+
源码包始终设置 `private: true`;内部清单的 archive 映射为空,默认安装会在网络请求前安全拒绝,内部候选必须显式提供同一候选目录的 `--artifact` / `--checksum`。公共构建在隔离 staging 中同步 npm 与双 Plugin 的稳定版本并写入四个平台 hash;macOS notarization 和 GitHub assets 回归通过后,才允许 `npm publish <tgz> --access public --auth-type=web`。
|
|
27
|
+
|
|
28
|
+
四个平台 Client archive 合计约 186 MB,而每位用户只需要约 44–49 MB 的当前平台内容,因此 npm 保持薄入口,`xfey/coconet` GitHub Release 承载 Client、独立 Server、checksum 和手动下载。
|
|
29
|
+
|
|
30
|
+
Coconet 不读取或迁移旧产品的 npm launcher、Runtime 根或用户状态。候选安装必须使用全新 HOME / XDG / npm prefix 验证直接安装路径。
|
|
31
|
+
|
|
32
|
+
代码、依赖、参数或下载边界变化时必须同步更新本文档、Bootstrap、验证记录和锁文件。
|
package/bin/coconet
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
|
|
3
|
+
# Keep the npm command stable while the native Runtime remains versioned under
|
|
4
|
+
# the user's Coconet data root. Node is used only when setup or an upgrade is
|
|
5
|
+
# required; successful daily commands replace this process with the Runtime.
|
|
6
|
+
|
|
7
|
+
launcher=$0
|
|
8
|
+
link_depth=0
|
|
9
|
+
while [ -L "$launcher" ]; do
|
|
10
|
+
link_depth=$((link_depth + 1))
|
|
11
|
+
if [ "$link_depth" -gt 16 ]; then
|
|
12
|
+
printf '%s\n' "coconet: the npm launcher contains too many symbolic links" >&2
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
launcher_directory=$(CDPATH= cd -P "$(dirname "$launcher")" 2>/dev/null && pwd) || {
|
|
16
|
+
printf '%s\n' "coconet: could not resolve the npm launcher directory" >&2
|
|
17
|
+
exit 1
|
|
18
|
+
}
|
|
19
|
+
link_target=$(readlink "$launcher") || {
|
|
20
|
+
printf '%s\n' "coconet: could not resolve the npm launcher link" >&2
|
|
21
|
+
exit 1
|
|
22
|
+
}
|
|
23
|
+
case $link_target in
|
|
24
|
+
/*) launcher=$link_target ;;
|
|
25
|
+
*) launcher=$launcher_directory/$link_target ;;
|
|
26
|
+
esac
|
|
27
|
+
done
|
|
28
|
+
|
|
29
|
+
launcher_directory=$(CDPATH= cd -P "$(dirname "$launcher")" 2>/dev/null && pwd) || {
|
|
30
|
+
printf '%s\n' "coconet: could not resolve the npm package directory" >&2
|
|
31
|
+
exit 1
|
|
32
|
+
}
|
|
33
|
+
package_root=$(dirname "$launcher_directory")
|
|
34
|
+
package_json=$package_root/package.json
|
|
35
|
+
setup_command=$package_root/cli.mjs
|
|
36
|
+
|
|
37
|
+
if [ ! -f "$package_json" ] || [ ! -x "$setup_command" ]; then
|
|
38
|
+
printf '%s\n' "coconet: the npm installation is incomplete; reinstall the global coconet package" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
package_version=$(LC_ALL=C sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$package_json" | LC_ALL=C sed -n '1p')
|
|
43
|
+
if [ -z "$package_version" ]; then
|
|
44
|
+
printf '%s\n' "coconet: could not read the npm package version" >&2
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
if [ -n "${XDG_DATA_HOME:-}" ]; then
|
|
49
|
+
data_root=$XDG_DATA_HOME
|
|
50
|
+
elif [ -n "${HOME:-}" ]; then
|
|
51
|
+
data_root=$HOME/.local/share
|
|
52
|
+
else
|
|
53
|
+
printf '%s\n' "coconet: HOME or XDG_DATA_HOME is required" >&2
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
install_root=$data_root/coconet
|
|
58
|
+
runtime=$install_root/current/bin/coconet
|
|
59
|
+
runtime_manifest=$install_root/current/manifest.json
|
|
60
|
+
|
|
61
|
+
runtime_version() {
|
|
62
|
+
if [ ! -f "$runtime_manifest" ]; then
|
|
63
|
+
return 1
|
|
64
|
+
fi
|
|
65
|
+
LC_ALL=C sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$runtime_manifest" | LC_ALL=C sed -n '1p'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
installed_version=$(runtime_version 2>/dev/null || true)
|
|
69
|
+
if [ -x "$runtime" ] && [ "$installed_version" = "$package_version" ]; then
|
|
70
|
+
exec "$runtime" "$@"
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
if [ -n "$installed_version" ]; then
|
|
74
|
+
printf 'Coconet Runtime %s does not match npm package %s; starting setup.\n' "$installed_version" "$package_version"
|
|
75
|
+
else
|
|
76
|
+
printf 'Coconet Runtime %s is not installed; starting setup.\n' "$package_version"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
"$setup_command"
|
|
80
|
+
setup_status=$?
|
|
81
|
+
if [ "$setup_status" -ne 0 ]; then
|
|
82
|
+
exit "$setup_status"
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
installed_version=$(runtime_version 2>/dev/null || true)
|
|
86
|
+
if [ ! -x "$runtime" ] || [ "$installed_version" != "$package_version" ]; then
|
|
87
|
+
printf '%s\n' "coconet: setup finished without activating the expected Runtime" >&2
|
|
88
|
+
exit 1
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
exec "$runtime" "$@"
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
4
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, join, resolve } from "node:path";
|
|
7
|
+
import { Readable, Transform } from "node:stream";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import * as tar from "tar";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
bundleName,
|
|
14
|
+
isSafeArchiveEntry,
|
|
15
|
+
parseChecksum,
|
|
16
|
+
parseInstallOptions,
|
|
17
|
+
releaseBaseURL,
|
|
18
|
+
releaseChecksum,
|
|
19
|
+
resolveTarget,
|
|
20
|
+
verifySHA256,
|
|
21
|
+
} from "./core.mjs";
|
|
22
|
+
|
|
23
|
+
const packageMetadata = JSON.parse(await readFile(new URL("./package.json", import.meta.url), "utf8"));
|
|
24
|
+
const packageVersion = packageMetadata.version;
|
|
25
|
+
const releaseManifest = JSON.parse(
|
|
26
|
+
await readFile(new URL("./release-manifest.json", import.meta.url), "utf8"),
|
|
27
|
+
);
|
|
28
|
+
const maximumArchiveBytes = 512 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
const arguments_ = process.argv.slice(2);
|
|
32
|
+
if (arguments_.length === 1 && ["version", "--version", "-v"].includes(arguments_[0])) {
|
|
33
|
+
process.stdout.write(`coconet ${packageVersion}\n`);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
let options;
|
|
37
|
+
try {
|
|
38
|
+
options = parseInstallOptions(arguments_, packageVersion);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
process.stderr.write(`${error.message}\n`);
|
|
41
|
+
printUsage();
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
if (options.help) {
|
|
45
|
+
printUsage(process.stdout);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const target = resolveTarget();
|
|
50
|
+
const rootName = bundleName(options.release, target);
|
|
51
|
+
const archiveName = `${rootName}.tar.gz`;
|
|
52
|
+
const temporaryRoot = await mkdtemp(join(tmpdir(), "coconet-installer-"));
|
|
53
|
+
try {
|
|
54
|
+
const archivePath = join(temporaryRoot, archiveName);
|
|
55
|
+
let expected;
|
|
56
|
+
if (options.artifact) {
|
|
57
|
+
const checksumPath = join(temporaryRoot, `${archiveName}.sha256`);
|
|
58
|
+
await copyVerifiedInput(resolve(options.artifact), archivePath, maximumArchiveBytes);
|
|
59
|
+
await copyVerifiedInput(resolve(options.checksum), checksumPath, 16 * 1024);
|
|
60
|
+
expected = parseChecksum(await readFile(checksumPath, "utf8"), archiveName);
|
|
61
|
+
} else {
|
|
62
|
+
expected = releaseChecksum(releaseManifest, options.release, target);
|
|
63
|
+
const baseURL = options.baseURL || releaseBaseURL(releaseManifest, options.release);
|
|
64
|
+
await downloadFile(new URL(archiveName, baseURL), archivePath, maximumArchiveBytes);
|
|
65
|
+
}
|
|
66
|
+
await verifySHA256(archivePath, expected);
|
|
67
|
+
|
|
68
|
+
const extractedRoot = join(temporaryRoot, "extracted");
|
|
69
|
+
await mkdir(extractedRoot, { mode: 0o700 });
|
|
70
|
+
const archiveModes = [];
|
|
71
|
+
await tar.x({
|
|
72
|
+
file: archivePath,
|
|
73
|
+
cwd: extractedRoot,
|
|
74
|
+
strict: true,
|
|
75
|
+
preservePaths: false,
|
|
76
|
+
chmod: true,
|
|
77
|
+
processUmask: 0,
|
|
78
|
+
umask: 0,
|
|
79
|
+
filter(path, entry) {
|
|
80
|
+
if (!isSafeArchiveEntry(path, entry.type, rootName)) {
|
|
81
|
+
throw new Error(`Coconet archive contains unsafe entry ${path}`);
|
|
82
|
+
}
|
|
83
|
+
archiveModes.push(validatedArchiveMode(path, entry.type, entry.mode, rootName));
|
|
84
|
+
return true;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
await restoreArchiveModes(extractedRoot, archiveModes);
|
|
88
|
+
const bundleRoot = join(extractedRoot, rootName);
|
|
89
|
+
await requireRegularFile(join(bundleRoot, "manifest.json"));
|
|
90
|
+
const bootstrap = join(bundleRoot, "bin", "coconet-bootstrap");
|
|
91
|
+
await requireRegularFile(bootstrap);
|
|
92
|
+
return await runBootstrap(bootstrap, [
|
|
93
|
+
...options.bootstrapArguments,
|
|
94
|
+
"--bundle",
|
|
95
|
+
bundleRoot,
|
|
96
|
+
]);
|
|
97
|
+
} finally {
|
|
98
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validatedArchiveMode(path, type, value, rootName) {
|
|
103
|
+
const normalizedPath = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
104
|
+
const mode = Number(value);
|
|
105
|
+
const accepted = Number.isInteger(mode) && mode >= 0 && mode <= 0o777
|
|
106
|
+
&& (type === "File"
|
|
107
|
+
? mode === 0o644 || mode === 0o755
|
|
108
|
+
: mode === (normalizedPath === rootName ? 0o700 : 0o755));
|
|
109
|
+
if (!accepted) {
|
|
110
|
+
throw new Error(`Coconet archive contains unsafe mode for ${path}`);
|
|
111
|
+
}
|
|
112
|
+
return { path: normalizedPath, type, mode };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function restoreArchiveModes(extractedRoot, entries) {
|
|
116
|
+
// File creation always applies the caller's process umask, even when tar's
|
|
117
|
+
// chmod option is enabled. The archive is hash-pinned and every mode above
|
|
118
|
+
// is allow-listed, so restore files first and directories deepest-first.
|
|
119
|
+
const ordered = [...entries].sort((left, right) => {
|
|
120
|
+
if (left.type !== right.type) return left.type === "File" ? -1 : 1;
|
|
121
|
+
return right.path.split("/").length - left.path.split("/").length;
|
|
122
|
+
});
|
|
123
|
+
for (const entry of ordered) {
|
|
124
|
+
await chmod(join(extractedRoot, entry.path), entry.mode);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function copyVerifiedInput(source, destination, maximumBytes) {
|
|
129
|
+
const info = await lstat(source);
|
|
130
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) {
|
|
131
|
+
throw new Error(`Coconet setup input is not an accepted regular file: ${basename(source)}`);
|
|
132
|
+
}
|
|
133
|
+
await pipeline(
|
|
134
|
+
createReadStream(source),
|
|
135
|
+
createSizeGuard(maximumBytes),
|
|
136
|
+
createWriteStream(destination, { mode: 0o600 }),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function downloadFile(url, destination, maximumBytes) {
|
|
141
|
+
if (url.protocol !== "https:") {
|
|
142
|
+
throw new Error("remote Coconet releases require HTTPS");
|
|
143
|
+
}
|
|
144
|
+
const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(60_000) });
|
|
145
|
+
if (!response.ok || !response.body) {
|
|
146
|
+
throw new Error(`failed to download ${url.href}: HTTP ${response.status}`);
|
|
147
|
+
}
|
|
148
|
+
if (new URL(response.url).protocol !== "https:") {
|
|
149
|
+
throw new Error("Coconet release download redirected away from HTTPS");
|
|
150
|
+
}
|
|
151
|
+
await pipeline(
|
|
152
|
+
Readable.fromWeb(response.body),
|
|
153
|
+
createSizeGuard(maximumBytes),
|
|
154
|
+
createWriteStream(destination, { mode: 0o600 }),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function createSizeGuard(maximumBytes) {
|
|
159
|
+
let bytes = 0;
|
|
160
|
+
return new Transform({
|
|
161
|
+
transform(chunk, _encoding, callback) {
|
|
162
|
+
bytes += chunk.length;
|
|
163
|
+
if (bytes > maximumBytes) {
|
|
164
|
+
callback(new Error("Coconet setup download exceeds the allowed size"));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
callback(null, chunk);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function requireRegularFile(path) {
|
|
173
|
+
const info = await lstat(path);
|
|
174
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
175
|
+
throw new Error("Coconet archive omitted a required regular file");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function runBootstrap(command, arguments_) {
|
|
180
|
+
return new Promise((resolveExit, reject) => {
|
|
181
|
+
const child = spawn(command, arguments_, { stdio: "inherit", env: process.env });
|
|
182
|
+
child.once("error", reject);
|
|
183
|
+
child.once("exit", (code, signal) => {
|
|
184
|
+
if (signal) {
|
|
185
|
+
reject(new Error(`coconet-bootstrap terminated by ${signal}`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
resolveExit(code ?? 1);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function printUsage(output = process.stderr) {
|
|
194
|
+
output.write(
|
|
195
|
+
"usage: coconet-setup [install] [--release VERSION] [--base-url HTTPS_URL | --artifact FILE --checksum FILE] [--yes] [--json] [--root DIR] [--bin-dir DIR] [--codex-command CMD] [--claude-command CMD]\n",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
process.exitCode = await main();
|
|
201
|
+
} catch (error) {
|
|
202
|
+
process.stderr.write(`${error.message}\n`);
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
}
|
package/core.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { posix } from "node:path";
|
|
4
|
+
|
|
5
|
+
const safeReleaseValue = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
6
|
+
|
|
7
|
+
export function resolveTarget(platform = process.platform, architecture = process.arch) {
|
|
8
|
+
const systems = { darwin: "darwin", linux: "linux" };
|
|
9
|
+
const architectures = { x64: "amd64", arm64: "arm64", amd64: "amd64" };
|
|
10
|
+
const targetOS = systems[platform];
|
|
11
|
+
const targetArchitecture = architectures[architecture];
|
|
12
|
+
if (!targetOS || !targetArchitecture) {
|
|
13
|
+
throw new Error(`Coconet does not support ${platform}/${architecture}`);
|
|
14
|
+
}
|
|
15
|
+
return { os: targetOS, architecture: targetArchitecture };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function bundleName(version, target) {
|
|
19
|
+
if (
|
|
20
|
+
!safeReleaseValue.test(version) ||
|
|
21
|
+
!safeReleaseValue.test(target.os) ||
|
|
22
|
+
!safeReleaseValue.test(target.architecture)
|
|
23
|
+
) {
|
|
24
|
+
throw new Error("bundle version or platform is invalid");
|
|
25
|
+
}
|
|
26
|
+
return `coconet-${version}-${target.os}-${target.architecture}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseChecksum(content, archiveName) {
|
|
30
|
+
const lines = content
|
|
31
|
+
.split(/\r?\n/u)
|
|
32
|
+
.map((line) => line.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
if (lines.length !== 1) {
|
|
35
|
+
throw new Error("checksum file must contain exactly one entry");
|
|
36
|
+
}
|
|
37
|
+
const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/u.exec(lines[0]);
|
|
38
|
+
if (!match || match[2] !== archiveName) {
|
|
39
|
+
throw new Error("checksum file does not match the selected archive");
|
|
40
|
+
}
|
|
41
|
+
return match[1].toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function releaseChecksum(manifest, version, target) {
|
|
45
|
+
if (
|
|
46
|
+
manifest?.schema_version !== 2 ||
|
|
47
|
+
manifest.version !== version ||
|
|
48
|
+
typeof manifest.archives !== "object" ||
|
|
49
|
+
manifest.archives === null ||
|
|
50
|
+
Array.isArray(manifest.archives)
|
|
51
|
+
) {
|
|
52
|
+
throw new Error("bundled release checksum manifest is invalid or does not match the release");
|
|
53
|
+
}
|
|
54
|
+
const archiveName = `${bundleName(version, target)}.tar.gz`;
|
|
55
|
+
const checksum = manifest.archives[archiveName];
|
|
56
|
+
if (typeof checksum !== "string" || !/^[0-9a-f]{64}$/u.test(checksum)) {
|
|
57
|
+
throw new Error(`bundled release checksum is unavailable for ${archiveName}`);
|
|
58
|
+
}
|
|
59
|
+
return checksum;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function releaseBaseURL(manifest, version) {
|
|
63
|
+
if (
|
|
64
|
+
manifest?.schema_version !== 2 ||
|
|
65
|
+
manifest.version !== version ||
|
|
66
|
+
!isGitHubRepository(manifest.repository) ||
|
|
67
|
+
manifest.release_tag !== `v${version}`
|
|
68
|
+
) {
|
|
69
|
+
throw new Error("bundled release location is invalid or does not match the release");
|
|
70
|
+
}
|
|
71
|
+
return `https://github.com/${manifest.repository}/releases/download/${manifest.release_tag}/`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isGitHubRepository(value) {
|
|
75
|
+
if (typeof value !== "string") return false;
|
|
76
|
+
const parts = value.split("/");
|
|
77
|
+
return parts.length === 2 && parts.every((part) => (
|
|
78
|
+
part !== "." && part !== ".." &&
|
|
79
|
+
/^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,98}[A-Za-z0-9])?$/u.test(part)
|
|
80
|
+
));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function verifySHA256(path, expected) {
|
|
84
|
+
const hash = createHash("sha256");
|
|
85
|
+
for await (const chunk of createReadStream(path)) {
|
|
86
|
+
hash.update(chunk);
|
|
87
|
+
}
|
|
88
|
+
const actual = hash.digest("hex");
|
|
89
|
+
if (actual !== expected.toLowerCase()) {
|
|
90
|
+
throw new Error("Coconet archive SHA-256 mismatch");
|
|
91
|
+
}
|
|
92
|
+
return actual;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isSafeArchiveEntry(path, type, expectedRoot) {
|
|
96
|
+
if (type !== "File" && type !== "Directory") {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (!path || path.includes("\\") || path.includes("\0") || path.startsWith("/")) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const normalized = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
103
|
+
if (!normalized || posix.normalize(normalized) !== normalized) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const parts = normalized.split("/");
|
|
107
|
+
if (parts.some((part) => part === "" || part === "." || part === "..")) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return normalized === expectedRoot || normalized.startsWith(`${expectedRoot}/`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseInstallOptions(arguments_, packageVersion) {
|
|
114
|
+
const options = {
|
|
115
|
+
release: packageVersion,
|
|
116
|
+
baseURL: "",
|
|
117
|
+
artifact: "",
|
|
118
|
+
checksum: "",
|
|
119
|
+
bootstrapArguments: ["install"],
|
|
120
|
+
help: false,
|
|
121
|
+
};
|
|
122
|
+
const args = [...arguments_];
|
|
123
|
+
if (args[0] === "install") {
|
|
124
|
+
args.shift();
|
|
125
|
+
}
|
|
126
|
+
const values = new Map([
|
|
127
|
+
["--release", "release"],
|
|
128
|
+
["--base-url", "baseURL"],
|
|
129
|
+
["--artifact", "artifact"],
|
|
130
|
+
["--checksum", "checksum"],
|
|
131
|
+
]);
|
|
132
|
+
const forwardedValues = new Set([
|
|
133
|
+
"--root",
|
|
134
|
+
"--bin-dir",
|
|
135
|
+
"--codex-command",
|
|
136
|
+
"--claude-command",
|
|
137
|
+
]);
|
|
138
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
139
|
+
const argument = args[index];
|
|
140
|
+
if (argument === "--help" || argument === "-h") {
|
|
141
|
+
options.help = true;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (argument === "--yes" || argument === "--json") {
|
|
145
|
+
options.bootstrapArguments.push(argument);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const value = args[index + 1];
|
|
149
|
+
if (!value || value.startsWith("--")) {
|
|
150
|
+
throw new Error(`missing value for ${argument}`);
|
|
151
|
+
}
|
|
152
|
+
if (values.has(argument)) {
|
|
153
|
+
options[values.get(argument)] = value;
|
|
154
|
+
index += 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (forwardedValues.has(argument)) {
|
|
158
|
+
options.bootstrapArguments.push(argument, value);
|
|
159
|
+
index += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
throw new Error(`unknown setup argument ${argument}`);
|
|
163
|
+
}
|
|
164
|
+
if (!safeReleaseValue.test(options.release)) {
|
|
165
|
+
throw new Error("release version is invalid");
|
|
166
|
+
}
|
|
167
|
+
if (Boolean(options.artifact) !== Boolean(options.checksum)) {
|
|
168
|
+
throw new Error("--artifact and --checksum must be provided together");
|
|
169
|
+
}
|
|
170
|
+
if (!options.artifact && options.baseURL) {
|
|
171
|
+
const baseURL = new URL(options.baseURL);
|
|
172
|
+
if (baseURL.protocol !== "https:") {
|
|
173
|
+
throw new Error("remote Coconet releases require HTTPS");
|
|
174
|
+
}
|
|
175
|
+
options.baseURL = baseURL.href.endsWith("/") ? baseURL.href : `${baseURL.href}/`;
|
|
176
|
+
}
|
|
177
|
+
if (options.bootstrapArguments.includes("--json") && !options.bootstrapArguments.includes("--yes")) {
|
|
178
|
+
throw new Error("--json requires --yes");
|
|
179
|
+
}
|
|
180
|
+
if (options.release === packageVersion) {
|
|
181
|
+
options.bootstrapArguments.splice(1, 0, "--no-bin-link");
|
|
182
|
+
}
|
|
183
|
+
return options;
|
|
184
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "coconet",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Install the verified Coconet runtime and Agent plugins for the current user.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"coconet": "./bin/coconet",
|
|
8
|
+
"coconet-setup": "./cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"README.md",
|
|
12
|
+
"bin/coconet",
|
|
13
|
+
"cli.mjs",
|
|
14
|
+
"core.mjs",
|
|
15
|
+
"release-manifest.json"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18.17"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test core.test.mjs cli.test.mjs"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"tar": "7.5.22"
|
|
28
|
+
},
|
|
29
|
+
"license": "UNLICENSED",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/xfey/coconet.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/xfey/coconet#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/xfey/coconet/issues"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 2,
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"repository": "xfey/coconet",
|
|
5
|
+
"release_tag": "v0.4.0",
|
|
6
|
+
"archives": {
|
|
7
|
+
"coconet-0.4.0-darwin-amd64.tar.gz": "692f37097b6ee7e84d60a1f68f5ccbace1a2c0fcb557be1d91b3418b02a16737",
|
|
8
|
+
"coconet-0.4.0-darwin-arm64.tar.gz": "3dfca82538a89b9cd84be60e79305fa630208333c8f545882f3ee02d6e8ea548",
|
|
9
|
+
"coconet-0.4.0-linux-amd64.tar.gz": "bb157a2804fdedc13174b6726002f3c7301c4d1ac16550e9d64b74de08897640",
|
|
10
|
+
"coconet-0.4.0-linux-arm64.tar.gz": "d629c9e15b82456bfc2d0a7774f4ed3d07b6c3236708afa1edaca14b873d00f2"
|
|
11
|
+
}
|
|
12
|
+
}
|