@steinscheng/zhaotongkuan 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.txt ADDED
@@ -0,0 +1,5 @@
1
+ Copyright (c) 2026 找同款. All rights reserved.
2
+
3
+ This software is proprietary and confidential. No permission is granted to
4
+ copy, redistribute, sublicense, sell, publish, reverse engineer, or use this
5
+ software except under a written license issued by the copyright holder.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @steinscheng/zhaotongkuan
2
+
3
+ 这是「找同款」的轻量安装引导器,不包含业务源码、激活码、渠道凭据或用户数据。它会获取与版本对应的 GitHub Release,校验整个 ZIP 的 SHA-256,然后以参数数组调用发行包内的确定性安装器。
4
+
5
+ ```bash
6
+ npx @steinscheng/zhaotongkuan@latest install --start --json
7
+ ```
8
+
9
+ 私有 GitHub Release 会优先使用已登录的 `gh` CLI 下载;引导器不读取、保存或输出 GitHub Token。也可以安装发行方通过其他可信渠道交付的本地 ZIP:
10
+
11
+ ```bash
12
+ npx @steinscheng/zhaotongkuan@latest install \
13
+ --archive "/absolute/path/to/zhaotongkuan.zip" \
14
+ --expected-sha256 "<trusted 64-character sha256>" \
15
+ --start \
16
+ --json
17
+ ```
18
+
19
+ 必须由发行方通过独立可信渠道提供 `--expected-sha256`。不要从和 ZIP 同一个未验证页面临时复制哈希。
20
+
21
+ 需要 macOS 或 Linux(x64/arm64)、Node.js 18.18+ 和 Python 3.10+。第一阶段使用 Python 运行已审计的安装事务;引导器不会安装系统级 Python、FFmpeg 或 Codex。当前发行包依赖 POSIX 启动脚本,不声明 Windows 支持。
22
+
23
+ 若机器同时保留系统旧版 Python 和新版 Python,可用 `--python /absolute/path/to/python3` 显式指定;macOS 引导器也会检查 Homebrew 的标准安装位置。
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/cli.js";
4
+
5
+ process.exitCode = await main(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@steinscheng/zhaotongkuan",
3
+ "version": "0.8.0",
4
+ "description": "Small, verifiable installer bootstrap for the Zhaotongkuan Codex plugin",
5
+ "type": "module",
6
+ "bin": {
7
+ "zhaotongkuan": "bin/zhaotongkuan.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE.txt"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test",
17
+ "pack:check": "npm pack --dry-run --json"
18
+ },
19
+ "engines": {
20
+ "node": ">=18.18.0"
21
+ },
22
+ "os": [
23
+ "darwin",
24
+ "linux"
25
+ ],
26
+ "cpu": [
27
+ "x64",
28
+ "arm64"
29
+ ],
30
+ "license": "SEE LICENSE IN LICENSE.txt",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+ssh://git@github.com/SchengSteins/zhaotongkuan.git"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "provenance": true,
38
+ "registry": "https://registry.npmjs.org/"
39
+ }
40
+ }
package/src/cli.js ADDED
@@ -0,0 +1,446 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream, createWriteStream, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { copyFile, lstat, mkdir, realpath } from "node:fs/promises";
4
+ import { get as httpsGet } from "node:https";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
11
+ const CHANNEL_PATH = join(PACKAGE_ROOT, "src", "release-channel.json");
12
+ const EXTRACTOR_PATH = join(PACKAGE_ROOT, "src", "extract_installer.py");
13
+ const PACKAGE_JSON = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
14
+ const SENSITIVE_KEY = /^(?:access_?token|authorization|cookie|credential|device_?id|license_?code|password|private_?key|refresh_?token|secret|session)$/iu;
15
+
16
+ export const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
17
+ export const ALLOWED_DOWNLOAD_HOSTS = new Set([
18
+ "github.com",
19
+ "objects.githubusercontent.com",
20
+ "release-assets.githubusercontent.com",
21
+ ]);
22
+
23
+ export class BootstrapError extends Error {
24
+ constructor(message, code = "install_failed") {
25
+ super(message);
26
+ this.name = "BootstrapError";
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ function cleanText(value, label) {
32
+ const text = String(value ?? "");
33
+ if (!text || /[\0\r\n]/u.test(text)) {
34
+ throw new BootstrapError(`${label} is empty or contains control characters`, "invalid_argument");
35
+ }
36
+ return text;
37
+ }
38
+
39
+ export function normalizeTarget(platform = process.platform, arch = process.arch) {
40
+ const platforms = { darwin: "darwin", linux: "linux" };
41
+ const architectures = { x64: "amd64", arm64: "arm64" };
42
+ if (!platforms[platform] || !architectures[arch]) {
43
+ throw new BootstrapError(`unsupported platform: ${platform}/${arch}`, "unsupported_platform");
44
+ }
45
+ return { platform: platforms[platform], arch: architectures[arch] };
46
+ }
47
+
48
+ export function validateSha256(value) {
49
+ const digest = String(value ?? "").trim().toLowerCase();
50
+ if (!/^[0-9a-f]{64}$/u.test(digest)) {
51
+ throw new BootstrapError("expected SHA-256 must be 64 hexadecimal characters", "invalid_checksum");
52
+ }
53
+ return digest;
54
+ }
55
+
56
+ export async function sha256File(path) {
57
+ const digest = createHash("sha256");
58
+ let size = 0;
59
+ for await (const chunk of createReadStream(path)) {
60
+ size += chunk.length;
61
+ if (size > MAX_ARCHIVE_BYTES) {
62
+ throw new BootstrapError("release archive exceeds the size limit", "archive_too_large");
63
+ }
64
+ digest.update(chunk);
65
+ }
66
+ return digest.digest("hex");
67
+ }
68
+
69
+ export async function verifyArchive(path, expectedSha256) {
70
+ const expected = validateSha256(expectedSha256);
71
+ const actual = await sha256File(path);
72
+ if (actual !== expected) {
73
+ throw new BootstrapError("release archive SHA-256 does not match the trusted value", "checksum_mismatch");
74
+ }
75
+ return actual;
76
+ }
77
+
78
+ export function parseArgs(argv) {
79
+ const result = {
80
+ command: "",
81
+ json: false,
82
+ dryRun: false,
83
+ start: false,
84
+ archive: "",
85
+ expectedSha256: "",
86
+ version: "",
87
+ python: "",
88
+ dataDir: "",
89
+ installRoot: "",
90
+ codexBin: "",
91
+ marketplaceName: "",
92
+ port: "",
93
+ };
94
+ const values = [...argv];
95
+ if (values[0] && !values[0].startsWith("-")) result.command = values.shift();
96
+ const valueFlags = new Map([
97
+ ["--archive", "archive"],
98
+ ["--archive-url", "archive"],
99
+ ["--expected-sha256", "expectedSha256"],
100
+ ["--version", "version"],
101
+ ["--python", "python"],
102
+ ["--data-dir", "dataDir"],
103
+ ["--install-root", "installRoot"],
104
+ ["--codex-bin", "codexBin"],
105
+ ["--marketplace-name", "marketplaceName"],
106
+ ["--port", "port"],
107
+ ]);
108
+ for (let index = 0; index < values.length; index += 1) {
109
+ const flag = values[index];
110
+ if (flag === "--json") result.json = true;
111
+ else if (flag === "--dry-run") result.dryRun = true;
112
+ else if (flag === "--start") result.start = true;
113
+ else if (flag === "--help" || flag === "-h") result.command = "help";
114
+ else if (flag === "--bootstrap-version") result.command = "version";
115
+ else if (valueFlags.has(flag)) {
116
+ const next = values[index + 1];
117
+ if (next === undefined) throw new BootstrapError(`${flag} requires a value`, "invalid_argument");
118
+ result[valueFlags.get(flag)] = cleanText(next, flag);
119
+ index += 1;
120
+ } else {
121
+ throw new BootstrapError(`unknown argument: ${flag}`, "invalid_argument");
122
+ }
123
+ }
124
+ return result;
125
+ }
126
+
127
+ export function loadChannel() {
128
+ const channel = JSON.parse(readFileSync(CHANNEL_PATH, "utf8"));
129
+ if (channel.schema_version !== 1 || !channel.repository || !channel.version) {
130
+ throw new BootstrapError("bundled release channel is invalid", "invalid_release_channel");
131
+ }
132
+ return channel;
133
+ }
134
+
135
+ export function resolveRelease(options, channel = loadChannel(), target = normalizeTarget()) {
136
+ const version = options.version || channel.version;
137
+ const artifact = channel.artifacts?.[`${target.platform}-${target.arch}`] ?? channel.artifacts?.any;
138
+ if (!artifact || !artifact.url || !artifact.sha256) {
139
+ throw new BootstrapError(`no release artifact for ${target.platform}/${target.arch}`, "unsupported_platform");
140
+ }
141
+ if (options.version && options.version !== channel.version && !artifact.url.includes("{version}")) {
142
+ throw new BootstrapError("the bundled channel does not provide a template for the requested version", "invalid_release_channel");
143
+ }
144
+ const archive = options.archive || artifact.url.replaceAll("{version}", version);
145
+ const expectedSha256 = options.expectedSha256 || artifact.sha256;
146
+ if (options.archive && !options.expectedSha256) {
147
+ throw new BootstrapError("--archive requires --expected-sha256 from a trusted channel", "missing_checksum");
148
+ }
149
+ return {
150
+ repository: cleanText(channel.repository, "repository"),
151
+ version: cleanText(version, "version"),
152
+ archive: cleanText(archive, "archive"),
153
+ expectedSha256: validateSha256(expectedSha256),
154
+ target,
155
+ };
156
+ }
157
+
158
+ export function githubReleaseCoordinates(rawUrl, expectedRepository) {
159
+ let parsed;
160
+ try {
161
+ parsed = new URL(rawUrl);
162
+ } catch {
163
+ return null;
164
+ }
165
+ if (
166
+ parsed.protocol !== "https:" ||
167
+ parsed.hostname !== "github.com" ||
168
+ parsed.username ||
169
+ parsed.password ||
170
+ parsed.search ||
171
+ parsed.hash
172
+ ) return null;
173
+ const parts = parsed.pathname.split("/").filter(Boolean).map(decodeURIComponent);
174
+ if (parts.length !== 6 || parts[2] !== "releases" || parts[3] !== "download") return null;
175
+ const repository = `${parts[0]}/${parts[1]}`;
176
+ if (repository.toLowerCase() !== expectedRepository.toLowerCase()) return null;
177
+ const tag = parts[4];
178
+ const asset = parts.slice(5).join("/");
179
+ if (!/^v?[0-9A-Za-z][0-9A-Za-z.+_-]*$/u.test(tag) || asset.includes("/") || asset.startsWith("-")) {
180
+ return null;
181
+ }
182
+ return { repository, tag, asset };
183
+ }
184
+
185
+ export function executableWorks(command) {
186
+ const probe = spawnSync(command, ["--version"], {
187
+ shell: false,
188
+ encoding: "utf8",
189
+ stdio: ["ignore", "pipe", "pipe"],
190
+ timeout: 10_000,
191
+ });
192
+ if (probe.error || probe.status !== 0) return false;
193
+ const output = `${String(probe.stdout || "")}\n${String(probe.stderr || "")}`;
194
+ const match = output.match(/Python\s+(\d+)\.(\d+)/u);
195
+ return Boolean(match) && (Number(match[1]) > 3 || (Number(match[1]) === 3 && Number(match[2]) >= 10));
196
+ }
197
+
198
+ export function findPython(preferred = "", candidates) {
199
+ const values = candidates ?? [
200
+ preferred,
201
+ process.env.PYTHON_BIN,
202
+ "python3",
203
+ "/opt/homebrew/bin/python3",
204
+ "/usr/local/bin/python3",
205
+ "/usr/bin/python3",
206
+ "python",
207
+ ];
208
+ for (const value of values) {
209
+ if (!value) continue;
210
+ const candidate = String(value);
211
+ if (/[\0\r\n]/u.test(candidate)) continue;
212
+ if (executableWorks(candidate)) return candidate;
213
+ }
214
+ throw new BootstrapError("Python 3.10+ was not found; install Python and retry", "python_missing");
215
+ }
216
+
217
+ function runGhDownload(coordinates, destination) {
218
+ const gh = spawnSync("gh", [
219
+ "release", "download", coordinates.tag,
220
+ "--repo", coordinates.repository,
221
+ "--pattern", coordinates.asset,
222
+ "--dir", dirname(destination),
223
+ "--clobber",
224
+ ], {
225
+ shell: false,
226
+ stdio: "ignore",
227
+ timeout: 180_000,
228
+ });
229
+ const downloaded = join(dirname(destination), coordinates.asset);
230
+ if (!gh.error && gh.status === 0 && existsSync(downloaded)) return downloaded;
231
+ return "";
232
+ }
233
+
234
+ function allowedHttpsUrl(rawUrl) {
235
+ const parsed = new URL(rawUrl);
236
+ if (parsed.protocol !== "https:" || !ALLOWED_DOWNLOAD_HOSTS.has(parsed.hostname)) {
237
+ throw new BootstrapError(`download host is not allowed: ${parsed.hostname || parsed.protocol}`, "download_denied");
238
+ }
239
+ return parsed;
240
+ }
241
+
242
+ function downloadHttps(rawUrl, destination, redirects = 0) {
243
+ const parsed = allowedHttpsUrl(rawUrl);
244
+ if (redirects > 3) return Promise.reject(new BootstrapError("too many release redirects", "download_failed"));
245
+ return new Promise((resolvePromise, rejectPromise) => {
246
+ const request = httpsGet(parsed, { headers: { "user-agent": `zhaotongkuan-bootstrap/${PACKAGE_JSON.version}` } }, (response) => {
247
+ const status = response.statusCode ?? 0;
248
+ if (status >= 300 && status < 400 && response.headers.location) {
249
+ response.resume();
250
+ let redirected;
251
+ try {
252
+ redirected = new URL(response.headers.location, parsed).toString();
253
+ allowedHttpsUrl(redirected);
254
+ } catch (error) {
255
+ rejectPromise(error);
256
+ return;
257
+ }
258
+ downloadHttps(redirected, destination, redirects + 1).then(resolvePromise, rejectPromise);
259
+ return;
260
+ }
261
+ if (status !== 200) {
262
+ response.resume();
263
+ rejectPromise(new BootstrapError(`release download returned HTTP ${status}`, "download_failed"));
264
+ return;
265
+ }
266
+ let size = 0;
267
+ const output = createWriteStream(destination, { flags: "wx", mode: 0o600 });
268
+ response.on("data", (chunk) => {
269
+ size += chunk.length;
270
+ if (size > MAX_ARCHIVE_BYTES) request.destroy(new BootstrapError("release archive exceeds the size limit", "archive_too_large"));
271
+ });
272
+ response.pipe(output);
273
+ output.on("finish", () => output.close(() => resolvePromise(destination)));
274
+ output.on("error", rejectPromise);
275
+ response.on("error", rejectPromise);
276
+ });
277
+ request.setTimeout(120_000, () => request.destroy(new BootstrapError("release download timed out", "download_failed")));
278
+ request.on("error", rejectPromise);
279
+ });
280
+ }
281
+
282
+ export async function acquireArchive(source, destination, repository) {
283
+ if (source.startsWith("file://")) {
284
+ const local = fileURLToPath(new URL(source));
285
+ const info = await lstat(local);
286
+ if (!info.isFile() || info.isSymbolicLink()) {
287
+ throw new BootstrapError("local release archive must be a regular non-symlink file", "archive_invalid");
288
+ }
289
+ await copyFile(await realpath(local), destination);
290
+ return destination;
291
+ }
292
+ if (isAbsolute(source) || source.startsWith(".") || existsSync(source)) {
293
+ const local = resolve(source);
294
+ const info = await lstat(local);
295
+ if (!info.isFile() || info.isSymbolicLink()) {
296
+ throw new BootstrapError("local release archive must be a regular non-symlink file", "archive_invalid");
297
+ }
298
+ await copyFile(await realpath(local), destination);
299
+ return destination;
300
+ }
301
+ const coordinates = githubReleaseCoordinates(source, repository);
302
+ if (!coordinates) {
303
+ throw new BootstrapError("initial HTTPS URL must be an exact release asset from the configured GitHub repository", "download_denied");
304
+ }
305
+ const downloaded = runGhDownload(coordinates, destination);
306
+ if (downloaded) return downloaded;
307
+ return downloadHttps(source, destination);
308
+ }
309
+
310
+ export function installerArgv(options, release, archivePath, installerPath) {
311
+ const argv = [
312
+ installerPath,
313
+ "--archive", archivePath,
314
+ "--expected-sha256", release.expectedSha256,
315
+ "--json",
316
+ ];
317
+ if (options.dryRun) argv.push("--dry-run");
318
+ if (options.start) argv.push("--start");
319
+ const mappings = [
320
+ [options.dataDir, "--data-dir"],
321
+ [options.installRoot, "--install-root"],
322
+ [options.codexBin, "--codex-bin"],
323
+ [options.marketplaceName, "--marketplace-name"],
324
+ [options.port, "--port"],
325
+ ];
326
+ for (const [value, flag] of mappings) if (value) argv.push(flag, value);
327
+ return argv;
328
+ }
329
+
330
+ function parseInstallerJson(stdout) {
331
+ const text = String(stdout ?? "").trim();
332
+ try {
333
+ const result = JSON.parse(text);
334
+ if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("not an object");
335
+ return result;
336
+ } catch {
337
+ throw new BootstrapError("deterministic installer did not return its JSON contract", "installer_contract_error");
338
+ }
339
+ }
340
+
341
+ export function sanitizeForJson(value, key = "", depth = 0) {
342
+ if (SENSITIVE_KEY.test(key)) return "[redacted]";
343
+ if (depth > 12) return "[truncated]";
344
+ if (Array.isArray(value)) return value.slice(0, 500).map((item) => sanitizeForJson(item, "", depth + 1));
345
+ if (value && typeof value === "object") {
346
+ return Object.fromEntries(
347
+ Object.entries(value).slice(0, 500).map(([childKey, childValue]) => [
348
+ childKey,
349
+ sanitizeForJson(childValue, childKey, depth + 1),
350
+ ]),
351
+ );
352
+ }
353
+ if (typeof value === "string") {
354
+ return value
355
+ .replace(/https:\/\/[^\s"'<>]+/giu, (raw) => {
356
+ try {
357
+ const url = new URL(raw);
358
+ return `${url.protocol}//${url.hostname}${url.pathname}${url.search ? "?[redacted]" : ""}`;
359
+ } catch {
360
+ return "[redacted-url]";
361
+ }
362
+ })
363
+ .replace(/((?:access_?token|authorization|cookie|password|secret|signature|token)=)[^\s&]+/giu, "$1[redacted]");
364
+ }
365
+ return value;
366
+ }
367
+
368
+ function help() {
369
+ return `Usage:\n npx @steinscheng/zhaotongkuan@latest install [options]\n\nSupported hosts:\n macOS or Linux on x64/arm64\n\nOptions:\n --archive <path-or-https-url> Install an explicit release archive\n --expected-sha256 <digest> Trusted SHA-256 (required with --archive)\n --dry-run Validate without writing\n --start Start and health-check the service\n --data-dir <path> Preserve business data outside the program\n --install-root <path> Override managed program directory\n --codex-bin <path> Use an explicit Codex CLI\n --python <path> Use an explicit Python 3.10+ executable\n --json Emit one JSON object\n`;
370
+ }
371
+
372
+ function emitError(error, jsonMode) {
373
+ const rawMessage = String(error?.message || error || "installation failed").replace(/[\r\n]+/gu, " ").slice(0, 1000);
374
+ const message = sanitizeForJson(rawMessage);
375
+ if (jsonMode) {
376
+ process.stdout.write(`${JSON.stringify({
377
+ schema_version: 1,
378
+ ok: false,
379
+ error: error?.code || "install_failed",
380
+ message,
381
+ manual_actions: [{ code: "resolve_install_error", message: "Resolve the reported issue and rerun the same install command." }],
382
+ })}\n`);
383
+ } else {
384
+ process.stderr.write(`Installation failed: ${message}\n`);
385
+ }
386
+ }
387
+
388
+ export async function main(argv) {
389
+ let options = { json: argv.includes("--json") };
390
+ let temporary = "";
391
+ try {
392
+ options = parseArgs(argv);
393
+ if (options.command === "help" || !options.command) {
394
+ process.stdout.write(help());
395
+ return 0;
396
+ }
397
+ if (options.command === "version") {
398
+ process.stdout.write(`${PACKAGE_JSON.version}\n`);
399
+ return 0;
400
+ }
401
+ if (options.command !== "install") throw new BootstrapError(`unknown command: ${options.command}`, "invalid_argument");
402
+
403
+ const release = resolveRelease(options);
404
+ const python = findPython(options.python);
405
+ temporary = mkdtempSync(join(tmpdir(), "zhaotongkuan-bootstrap-"));
406
+ await mkdir(temporary, { recursive: true });
407
+ const archiveDestination = join(temporary, basename(new URL(release.archive, "file:///").pathname) || "release.zip");
408
+ const archivePath = await acquireArchive(release.archive, archiveDestination, release.repository);
409
+ await verifyArchive(archivePath, release.expectedSha256);
410
+
411
+ const installerPath = join(temporary, "agent_install.py");
412
+ const extracted = spawnSync(python, [EXTRACTOR_PATH, archivePath, installerPath], {
413
+ shell: false,
414
+ encoding: "utf8",
415
+ stdio: ["ignore", "pipe", "pipe"],
416
+ timeout: 30_000,
417
+ });
418
+ if (extracted.error || extracted.status !== 0) {
419
+ throw new BootstrapError("could not read the deterministic installer from the verified release", "archive_invalid");
420
+ }
421
+ const child = spawnSync(python, installerArgv(options, release, archivePath, installerPath), {
422
+ shell: false,
423
+ encoding: "utf8",
424
+ stdio: ["ignore", "pipe", "pipe"],
425
+ timeout: 30 * 60_000,
426
+ });
427
+ if (child.error) throw new BootstrapError(`installer process failed: ${child.error.message}`, "installer_failed");
428
+ const result = sanitizeForJson(parseInstallerJson(child.stdout));
429
+ const output = {
430
+ ...result,
431
+ bootstrap: {
432
+ version: PACKAGE_JSON.version,
433
+ release_version: release.version,
434
+ target: `${release.target.platform}-${release.target.arch}`,
435
+ checksum_verified: true,
436
+ },
437
+ };
438
+ process.stdout.write(`${JSON.stringify(output)}\n`);
439
+ return child.status === 0 && result.ok === true ? 0 : (child.status || 1);
440
+ } catch (error) {
441
+ emitError(error, Boolean(options.json));
442
+ return 1;
443
+ } finally {
444
+ if (temporary) rmSync(temporary, { recursive: true, force: true });
445
+ }
446
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Extract one fixed, size-limited installer member from a verified release ZIP."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import stat
8
+ import sys
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+
13
+ EXPECTED_MEMBER = "zhaotongkuan/scripts/agent_install.py"
14
+ MAX_INSTALLER_BYTES = 2 * 1024 * 1024
15
+
16
+
17
+ def fail(message: str) -> None:
18
+ print(message, file=sys.stderr)
19
+ raise SystemExit(1)
20
+
21
+
22
+ def main() -> None:
23
+ if len(sys.argv) != 3:
24
+ fail("expected archive and destination arguments")
25
+ archive = Path(sys.argv[1])
26
+ destination = Path(sys.argv[2])
27
+ if destination.exists() or destination.is_symlink():
28
+ fail("installer destination already exists")
29
+ try:
30
+ with zipfile.ZipFile(archive, "r") as release:
31
+ matches = [item for item in release.infolist() if item.filename == EXPECTED_MEMBER]
32
+ if len(matches) != 1:
33
+ fail("release must contain exactly one fixed installer member")
34
+ member = matches[0]
35
+ mode = (member.external_attr >> 16) & 0xFFFF
36
+ if member.is_dir() or stat.S_ISLNK(mode) or member.flag_bits & 0x1:
37
+ fail("release installer member is not a plain unencrypted file")
38
+ if member.file_size <= 0 or member.file_size > MAX_INSTALLER_BYTES:
39
+ fail("release installer member has an invalid size")
40
+ destination.parent.mkdir(parents=True, exist_ok=True)
41
+ written = 0
42
+ with release.open(member, "r") as source, destination.open("xb") as target:
43
+ while True:
44
+ chunk = source.read(64 * 1024)
45
+ if not chunk:
46
+ break
47
+ written += len(chunk)
48
+ if written > MAX_INSTALLER_BYTES:
49
+ fail("release installer member exceeds the size limit")
50
+ target.write(chunk)
51
+ if written != member.file_size:
52
+ fail("release installer member size does not match its ZIP record")
53
+ os.chmod(destination, 0o700)
54
+ except (OSError, zipfile.BadZipFile, zipfile.LargeZipFile) as error:
55
+ fail(f"cannot read verified release ZIP: {error}")
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
@@ -0,0 +1,11 @@
1
+ {
2
+ "schema_version": 1,
3
+ "repository": "SchengSteins/zhaotongkuan",
4
+ "version": "0.8.0",
5
+ "artifacts": {
6
+ "any": {
7
+ "url": "https://github.com/SchengSteins/zhaotongkuan/releases/download/v0.8.0/zhaotongkuan-0.8.0.zip",
8
+ "sha256": "4bee7fcb5a24facbaa8d943ce8a41a6e900464de9f371bd06a545bc4aae14ad5"
9
+ }
10
+ }
11
+ }