yunti-browser-runtime 0.1.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -0
  3. package/bin/yunti-browser-runtime.js +86 -0
  4. package/docs/EXECUTION_PLAN.md +1278 -0
  5. package/docs/INSTALL.md +205 -0
  6. package/docs/PROJECT_INTENT.md +44 -0
  7. package/docs/PROJECT_STATUS.md +263 -0
  8. package/docs/PUBLISHING_BLOCKERS.md +110 -0
  9. package/docs/RELEASE.md +148 -0
  10. package/docs/ROADMAP.md +42 -0
  11. package/docs/SECURITY.md +56 -0
  12. package/docs/TOOL_GUIDE.md +69 -0
  13. package/extension/background.js +55 -0
  14. package/extension/cdp.js +582 -0
  15. package/extension/content.css +9 -0
  16. package/extension/content.js +946 -0
  17. package/extension/manifest.json +35 -0
  18. package/extension/network-monitor.js +140 -0
  19. package/extension/popup.css +66 -0
  20. package/extension/popup.html +30 -0
  21. package/extension/popup.js +55 -0
  22. package/extension/session-manager.js +332 -0
  23. package/extension/settings.js +94 -0
  24. package/extension/tool-handlers.js +1158 -0
  25. package/lib/runtime-paths.js +39 -0
  26. package/mcp/bridge-hub.js +604 -0
  27. package/mcp/http-server.js +326 -0
  28. package/mcp/json-rpc.js +35 -0
  29. package/mcp/memory.js +126 -0
  30. package/mcp/redaction.js +94 -0
  31. package/mcp/server.js +269 -0
  32. package/mcp/tools.js +1092 -0
  33. package/package.json +60 -0
  34. package/scripts/check-package-metadata.js +131 -0
  35. package/scripts/check-published-package.js +113 -0
  36. package/scripts/doctor.js +163 -0
  37. package/scripts/package-extension.js +137 -0
  38. package/scripts/print-config.js +116 -0
  39. package/scripts/release-check.js +472 -0
  40. package/skills/yunti-browser-runtime/SKILL.md +77 -0
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "yunti-browser-runtime",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Local browser runtime and MCP tools for AI agents.",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "yunti-browser-runtime": "bin/yunti-browser-runtime.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "docs/",
13
+ "extension/",
14
+ "lib/",
15
+ "mcp/",
16
+ "scripts/",
17
+ "skills/",
18
+ "README.md",
19
+ "LICENSE",
20
+ "package.json"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/dingguangyi0/yunti-browser-runtime.git"
25
+ },
26
+ "keywords": [
27
+ "mcp",
28
+ "browser-automation",
29
+ "ai-agent",
30
+ "chrome-extension",
31
+ "cdp",
32
+ "local-first"
33
+ ],
34
+ "homepage": "https://github.com/dingguangyi0/yunti-browser-runtime#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/dingguangyi0/yunti-browser-runtime/issues"
37
+ },
38
+ "packageManager": "npm@10",
39
+ "scripts": {
40
+ "mcp": "node mcp/server.js",
41
+ "bridge": "YUNTI_BROWSER_BRIDGE_ONLY=1 node mcp/server.js",
42
+ "doctor": "node scripts/doctor.js",
43
+ "doctor:json": "node scripts/doctor.js 2>/dev/null",
44
+ "print-config": "node scripts/print-config.js",
45
+ "package:extension": "node scripts/package-extension.js",
46
+ "check:metadata": "node scripts/check-package-metadata.js",
47
+ "release:check": "node scripts/release-check.js",
48
+ "release:prepublish": "npm run check:metadata && npm run release:check",
49
+ "release:whoami": "npm whoami --registry=https://registry.npmjs.org/",
50
+ "release:dry-run": "npm run release:prepublish && npm publish --dry-run --registry=https://registry.npmjs.org/",
51
+ "release:publish": "npm run release:prepublish && npm run release:whoami && npm publish --registry=https://registry.npmjs.org/",
52
+ "release:verify-published": "node scripts/check-published-package.js",
53
+ "test": "node --test tests/*.test.js",
54
+ "test:e2e": "node --test tests/e2e.test.js",
55
+ "check": "node --check bin/yunti-browser-runtime.js && node --check lib/runtime-paths.js && node --check mcp/server.js && node --check mcp/http-server.js && node --check mcp/bridge-hub.js && node --check mcp/tools.js && node --check mcp/redaction.js && node --check mcp/memory.js && node --check mcp/json-rpc.js && node --check scripts/doctor.js && node --check scripts/print-config.js && node --check scripts/package-extension.js && node --check scripts/check-package-metadata.js && node --check scripts/check-published-package.js && node --check scripts/release-check.js && node --check tests/e2e.test.js && node --check extension/background.js && node --check extension/cdp.js && node --check extension/session-manager.js && node --check extension/tool-handlers.js && node --check extension/settings.js && node --check extension/network-monitor.js && node --check extension/content.js && node --check extension/popup.js"
56
+ },
57
+ "engines": {
58
+ "node": ">=22"
59
+ }
60
+ }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process"
3
+ import https from "node:https"
4
+ import packageJson from "../package.json" with { type: "json" }
5
+
6
+ const timeoutMs = Number(process.env.YUNTI_BROWSER_METADATA_CHECK_TIMEOUT_MS || 15000)
7
+ const retryCount = Number(process.env.YUNTI_BROWSER_METADATA_CHECK_RETRIES || 2)
8
+
9
+ function normalizeRepositoryUrl(value) {
10
+ return String(value || "")
11
+ .trim()
12
+ .replace(/^git\+/, "")
13
+ .replace(/\.git$/, "")
14
+ }
15
+
16
+ function sleep(ms) {
17
+ return new Promise((resolve) => setTimeout(resolve, ms))
18
+ }
19
+
20
+ function isRetryableHeadResult(result) {
21
+ return result.status === 0 || result.status >= 500
22
+ }
23
+
24
+ function requestHeadOnce(url) {
25
+ return new Promise((resolve) => {
26
+ const req = https.request(url, { method: "HEAD", timeout: timeoutMs }, (res) => {
27
+ const location = res.headers.location
28
+ if (
29
+ location &&
30
+ [301, 302, 303, 307, 308].includes(Number(res.statusCode))
31
+ ) {
32
+ resolve(requestHeadOnce(new URL(location, url).toString()))
33
+ return
34
+ }
35
+ resolve({
36
+ ok: Number(res.statusCode) >= 200 && Number(res.statusCode) < 400,
37
+ status: Number(res.statusCode),
38
+ url,
39
+ })
40
+ })
41
+ req.on("timeout", () => {
42
+ req.destroy(new Error("request timed out"))
43
+ })
44
+ req.on("error", (error) => {
45
+ resolve({ ok: false, status: 0, url, error: error.message })
46
+ })
47
+ req.end()
48
+ })
49
+ }
50
+
51
+ async function requestHead(url) {
52
+ let result
53
+ for (let attempt = 0; attempt <= retryCount; attempt += 1) {
54
+ result = await requestHeadOnce(url)
55
+ if (!isRetryableHeadResult(result)) return result
56
+ if (attempt < retryCount) await sleep(250 * (attempt + 1))
57
+ }
58
+ return {
59
+ ...result,
60
+ attempts: retryCount + 1,
61
+ }
62
+ }
63
+
64
+ function npmViewPackage(name) {
65
+ try {
66
+ const version = execFileSync("npm", ["view", name, "version", "--json"], {
67
+ encoding: "utf8",
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ }).trim()
70
+ return {
71
+ ok: true,
72
+ published: true,
73
+ version: JSON.parse(version || "null"),
74
+ }
75
+ } catch (error) {
76
+ const output = `${error.stdout || ""}\n${error.stderr || ""}`
77
+ if (output.includes("E404")) {
78
+ return {
79
+ ok: true,
80
+ published: false,
81
+ note: "Package is not currently published; this is acceptable for a first release if the name is intended.",
82
+ }
83
+ }
84
+ return {
85
+ ok: false,
86
+ published: false,
87
+ error: output.trim() || error.message,
88
+ }
89
+ }
90
+ }
91
+
92
+ const repositoryUrl = normalizeRepositoryUrl(packageJson.repository?.url)
93
+ const homepageUrl = String(packageJson.homepage || "").trim()
94
+ const normalizedHomepageUrl = homepageUrl.replace(/#.*$/, "")
95
+ const bugsUrl = String(packageJson.bugs?.url || "").trim()
96
+
97
+ const repositoryCheck = await requestHead(repositoryUrl)
98
+ const homepageCheck =
99
+ normalizedHomepageUrl === repositoryUrl
100
+ ? { ...repositoryCheck, url: normalizedHomepageUrl }
101
+ : await requestHead(normalizedHomepageUrl)
102
+
103
+ const checks = {
104
+ repository: repositoryCheck,
105
+ homepage: homepageCheck,
106
+ bugs: await requestHead(bugsUrl),
107
+ npm: npmViewPackage(packageJson.name),
108
+ }
109
+
110
+ const ok = checks.repository.ok && checks.homepage.ok && checks.bugs.ok && checks.npm.ok
111
+ const report = {
112
+ ok,
113
+ checkedAt: new Date().toISOString(),
114
+ package: {
115
+ name: packageJson.name,
116
+ version: packageJson.version,
117
+ repository: packageJson.repository,
118
+ homepage: packageJson.homepage,
119
+ bugs: packageJson.bugs,
120
+ },
121
+ checks,
122
+ nextSteps: ok
123
+ ? []
124
+ : [
125
+ "Create or publicize the GitHub repository, or update repository/homepage/bugs in package.json to real public URLs.",
126
+ "If this is the first npm release, npm E404 for the package name can be accepted.",
127
+ ],
128
+ }
129
+
130
+ console.log(JSON.stringify(report, null, 2))
131
+ if (!ok) process.exitCode = 1
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process"
3
+ import packageJson from "../package.json" with { type: "json" }
4
+
5
+ const registry = process.env.YUNTI_BROWSER_NPM_REGISTRY || "https://registry.npmjs.org/"
6
+
7
+ function normalizeRepositoryUrl(value) {
8
+ const raw = typeof value === "string" ? value : value?.url
9
+ return String(raw || "")
10
+ .trim()
11
+ .replace(/^git\+/, "")
12
+ .replace(/\.git$/, "")
13
+ }
14
+
15
+ function cleanNpmError(output) {
16
+ return String(output || "")
17
+ .split("\n")
18
+ .filter((line) => !line.includes("A complete log of this run can be found in:"))
19
+ .join("\n")
20
+ .trim()
21
+ }
22
+
23
+ function npmView(spec) {
24
+ try {
25
+ const output = execFileSync(
26
+ "npm",
27
+ ["view", spec, "--json", `--registry=${registry}`],
28
+ {
29
+ encoding: "utf8",
30
+ stdio: ["ignore", "pipe", "pipe"],
31
+ }
32
+ ).trim()
33
+ return {
34
+ ok: true,
35
+ data: JSON.parse(output || "{}"),
36
+ }
37
+ } catch (error) {
38
+ const output = cleanNpmError(`${error.stdout || ""}\n${error.stderr || ""}`)
39
+ return {
40
+ ok: false,
41
+ unpublished: output.includes("E404"),
42
+ error: output || error.message,
43
+ }
44
+ }
45
+ }
46
+
47
+ const spec = `${packageJson.name}@${packageJson.version}`
48
+ const result = npmView(spec)
49
+ const data = result.data || {}
50
+
51
+ function comparePublishedValue(actual, expected) {
52
+ if (!result.ok) {
53
+ return {
54
+ ok: false,
55
+ skipped: true,
56
+ reason: "package version is not published",
57
+ expected,
58
+ }
59
+ }
60
+ return {
61
+ ok: actual === expected,
62
+ expected,
63
+ actual,
64
+ }
65
+ }
66
+
67
+ const checks = {
68
+ published: {
69
+ ok: result.ok,
70
+ registry,
71
+ spec,
72
+ unpublished: result.unpublished || undefined,
73
+ error: result.ok ? undefined : result.error,
74
+ },
75
+ name: comparePublishedValue(data.name, packageJson.name),
76
+ version: comparePublishedValue(data.version, packageJson.version),
77
+ repository: comparePublishedValue(
78
+ normalizeRepositoryUrl(data.repository),
79
+ normalizeRepositoryUrl(packageJson.repository)
80
+ ),
81
+ homepage: comparePublishedValue(data.homepage, packageJson.homepage),
82
+ bugs: comparePublishedValue(data.bugs?.url, packageJson.bugs?.url),
83
+ tarball: result.ok
84
+ ? {
85
+ ok: typeof data.dist?.tarball === "string" && data.dist.tarball.includes(`${packageJson.name}-`),
86
+ actual: data.dist?.tarball,
87
+ }
88
+ : {
89
+ ok: false,
90
+ skipped: true,
91
+ reason: "package version is not published",
92
+ },
93
+ }
94
+
95
+ const ok = Object.values(checks).every((check) => check.ok)
96
+ const report = {
97
+ ok,
98
+ checkedAt: new Date().toISOString(),
99
+ package: {
100
+ name: packageJson.name,
101
+ version: packageJson.version,
102
+ },
103
+ checks,
104
+ nextSteps: ok
105
+ ? []
106
+ : [
107
+ "Publish the package with npm run release:publish, or wait for the npm registry to reflect the just-published version.",
108
+ "If metadata differs, update package.json before publishing a follow-up version.",
109
+ ],
110
+ }
111
+
112
+ console.log(JSON.stringify(report, null, 2))
113
+ if (!ok) process.exitCode = 1
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ import http from "node:http"
3
+ import { existsSync } from "node:fs"
4
+ import { dirname, join, resolve } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const bridgeUrl = normalizeBaseUrl(
8
+ process.env.YUNTI_BROWSER_BRIDGE_URL || "http://127.0.0.1:48887"
9
+ )
10
+ const routeUserId = process.env.YUNTI_BROWSER_USER_ID || "local"
11
+ const bridgeToken = String(process.env.YUNTI_BROWSER_BRIDGE_TOKEN || "").trim()
12
+ const bridgeTokenHeader = "x-yunti-browser-token"
13
+ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..")
14
+ const mcpServerPath = join(rootDir, "mcp", "server.js")
15
+ const skillPath = join(rootDir, "skills", "yunti-browser-runtime", "SKILL.md")
16
+
17
+ function normalizeBaseUrl(value) {
18
+ return String(value || "").trim().replace(/\/+$/, "")
19
+ }
20
+
21
+ function nodeVersionStatus() {
22
+ const major = Number(process.versions.node.split(".")[0])
23
+ return {
24
+ ok: Number.isFinite(major) && major >= 22,
25
+ version: process.version,
26
+ required: ">=22",
27
+ }
28
+ }
29
+
30
+ function getJson(url) {
31
+ return new Promise((resolve, reject) => {
32
+ const req = http.get(url, {
33
+ headers: bridgeToken ? { [bridgeTokenHeader]: bridgeToken } : {},
34
+ }, (res) => {
35
+ let body = ""
36
+ res.setEncoding("utf8")
37
+ res.on("data", (chunk) => {
38
+ body += chunk
39
+ })
40
+ res.on("end", () => {
41
+ try {
42
+ resolve({ status: res.statusCode, json: JSON.parse(body || "{}") })
43
+ } catch (error) {
44
+ reject(error)
45
+ }
46
+ })
47
+ })
48
+ req.on("error", reject)
49
+ req.setTimeout(3000, () => {
50
+ req.destroy(new Error("bridge health check timed out"))
51
+ })
52
+ })
53
+ }
54
+
55
+ async function checkBridge() {
56
+ try {
57
+ const health = await getJson(
58
+ `${bridgeUrl}/health?userId=${encodeURIComponent(routeUserId)}`
59
+ )
60
+ const authorized = health.json?.authorized !== false
61
+ const sessions = Array.isArray(health.json?.sessions) ? health.json.sessions : []
62
+ const activeSessionId = health.json?.activeSessionId || null
63
+ return {
64
+ ok: health.status === 200 && authorized,
65
+ reachable: health.status === 200,
66
+ authorized,
67
+ status: health.status,
68
+ url: bridgeUrl,
69
+ tokenConfigured: Boolean(bridgeToken),
70
+ tokenHeader: bridgeTokenHeader,
71
+ userId: routeUserId,
72
+ sessionCount: Number(health.json?.sessionCount || sessions.length || 0),
73
+ visibleSessionCount: sessions.length,
74
+ activeSessionId,
75
+ activeSession: sessions.find((session) => session.browserSessionId === activeSessionId) || null,
76
+ extensionConnected: sessions.length > 0,
77
+ raw: health.json,
78
+ }
79
+ } catch (error) {
80
+ return {
81
+ ok: false,
82
+ reachable: false,
83
+ authorized: false,
84
+ url: bridgeUrl,
85
+ tokenConfigured: Boolean(bridgeToken),
86
+ tokenHeader: bridgeTokenHeader,
87
+ userId: routeUserId,
88
+ error: error.message,
89
+ sessionCount: 0,
90
+ visibleSessionCount: 0,
91
+ activeSessionId: null,
92
+ activeSession: null,
93
+ extensionConnected: false,
94
+ }
95
+ }
96
+ }
97
+
98
+ function checkFile(path) {
99
+ return {
100
+ ok: existsSync(path),
101
+ path,
102
+ }
103
+ }
104
+
105
+ function buildNextSteps(checks) {
106
+ const steps = []
107
+ if (!checks.node.ok) {
108
+ steps.push("Install Node.js 22 or newer, then rerun npm run doctor.")
109
+ }
110
+ if (!checks.bridge.reachable) {
111
+ steps.push("Start the local bridge with npm run bridge.")
112
+ }
113
+ if (checks.bridge.reachable && !checks.bridge.authorized) {
114
+ steps.push("Set YUNTI_BROWSER_BRIDGE_TOKEN to the bridge token printed by npm run bridge.")
115
+ steps.push("Save the same token in the extension popup.")
116
+ }
117
+ if (checks.bridge.ok && !checks.bridge.extensionConnected) {
118
+ steps.push("Load the extension, open an http/https page, then refresh the target page.")
119
+ }
120
+ if (!checks.mcpServer.ok) {
121
+ steps.push(`Restore the MCP server file at ${checks.mcpServer.path}.`)
122
+ }
123
+ if (!checks.skill.ok) {
124
+ steps.push(`Restore the agent skill at ${checks.skill.path}.`)
125
+ }
126
+ return steps
127
+ }
128
+
129
+ function humanSummary(report) {
130
+ const lines = [
131
+ `Yunti Browser Runtime doctor: ${report.ok ? "OK" : "needs attention"}`,
132
+ `- Node: ${report.checks.node.version} (${report.checks.node.ok ? "ok" : "requires >=22"})`,
133
+ `- Bridge: ${report.checks.bridge.reachable ? report.checks.bridge.url : "not reachable"}`,
134
+ `- Token: ${report.checks.bridge.authorized ? "valid" : "missing or invalid"}`,
135
+ `- Sessions: ${report.checks.bridge.visibleSessionCount} visible, active ${report.checks.bridge.activeSessionId || "none"}`,
136
+ `- Extension: ${report.checks.bridge.extensionConnected ? "connected" : "not detected"}`,
137
+ `- MCP server: ${report.checks.mcpServer.ok ? "found" : "missing"}`,
138
+ `- Skill: ${report.checks.skill.ok ? "found" : "missing"}`,
139
+ ]
140
+ if (report.nextSteps.length) {
141
+ lines.push("Next steps:")
142
+ for (const step of report.nextSteps) lines.push(`- ${step}`)
143
+ }
144
+ return lines.join("\n")
145
+ }
146
+
147
+ const checks = {
148
+ node: nodeVersionStatus(),
149
+ bridge: await checkBridge(),
150
+ mcpServer: checkFile(mcpServerPath),
151
+ skill: checkFile(skillPath),
152
+ }
153
+ const nextSteps = buildNextSteps(checks)
154
+ const report = {
155
+ ok: checks.node.ok && checks.bridge.ok && checks.mcpServer.ok && checks.skill.ok,
156
+ checkedAt: new Date().toISOString(),
157
+ checks,
158
+ nextSteps,
159
+ }
160
+
161
+ console.log(JSON.stringify(report, null, 2))
162
+ console.error(humanSummary(report))
163
+ if (!report.ok) process.exitCode = 1
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, rm } from "node:fs/promises"
3
+ import { createWriteStream } from "node:fs"
4
+ import { dirname, join, resolve } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import { createHash } from "node:crypto"
7
+ import { execFile } from "node:child_process"
8
+ import { promisify } from "node:util"
9
+ import packageJson from "../package.json" with { type: "json" }
10
+
11
+ const execFileAsync = promisify(execFile)
12
+ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..")
13
+ const extensionDir = join(rootDir, "extension")
14
+ const distDir = join(rootDir, "dist")
15
+ const version = packageJson.version || "0.0.0"
16
+ const zipName = `yunti-browser-runtime-extension-${version}.zip`
17
+ const zipPath = join(distDir, zipName)
18
+ const allowedFiles = [
19
+ "background.js",
20
+ "cdp.js",
21
+ "content.css",
22
+ "content.js",
23
+ "network-monitor.js",
24
+ "manifest.json",
25
+ "popup.css",
26
+ "popup.html",
27
+ "popup.js",
28
+ "session-manager.js",
29
+ "settings.js",
30
+ "tool-handlers.js",
31
+ ]
32
+
33
+ async function zipWithSystemZip() {
34
+ await execFileAsync("zip", ["-X", "-q", zipPath, ...allowedFiles], {
35
+ cwd: extensionDir,
36
+ })
37
+ }
38
+
39
+ async function writeMinimalZip() {
40
+ const chunks = []
41
+ const central = []
42
+ let offset = 0
43
+
44
+ for (const name of allowedFiles) {
45
+ const data = await import("node:fs/promises").then((fs) =>
46
+ fs.readFile(join(extensionDir, name))
47
+ )
48
+ const nameBytes = Buffer.from(name)
49
+ const crc = crc32(data)
50
+ const localHeader = Buffer.alloc(30)
51
+ localHeader.writeUInt32LE(0x04034b50, 0)
52
+ localHeader.writeUInt16LE(20, 4)
53
+ localHeader.writeUInt16LE(0, 6)
54
+ localHeader.writeUInt16LE(0, 8)
55
+ localHeader.writeUInt16LE(0, 10)
56
+ localHeader.writeUInt16LE(0, 12)
57
+ localHeader.writeUInt32LE(crc, 14)
58
+ localHeader.writeUInt32LE(data.length, 18)
59
+ localHeader.writeUInt32LE(data.length, 22)
60
+ localHeader.writeUInt16LE(nameBytes.length, 26)
61
+ localHeader.writeUInt16LE(0, 28)
62
+ chunks.push(localHeader, nameBytes, data)
63
+
64
+ const centralHeader = Buffer.alloc(46)
65
+ centralHeader.writeUInt32LE(0x02014b50, 0)
66
+ centralHeader.writeUInt16LE(20, 4)
67
+ centralHeader.writeUInt16LE(20, 6)
68
+ centralHeader.writeUInt16LE(0, 8)
69
+ centralHeader.writeUInt16LE(0, 10)
70
+ centralHeader.writeUInt16LE(0, 12)
71
+ centralHeader.writeUInt16LE(0, 14)
72
+ centralHeader.writeUInt32LE(crc, 16)
73
+ centralHeader.writeUInt32LE(data.length, 20)
74
+ centralHeader.writeUInt32LE(data.length, 24)
75
+ centralHeader.writeUInt16LE(nameBytes.length, 28)
76
+ centralHeader.writeUInt16LE(0, 30)
77
+ centralHeader.writeUInt16LE(0, 32)
78
+ centralHeader.writeUInt16LE(0, 34)
79
+ centralHeader.writeUInt16LE(0, 36)
80
+ centralHeader.writeUInt32LE(0, 38)
81
+ centralHeader.writeUInt32LE(offset, 42)
82
+ central.push(centralHeader, nameBytes)
83
+ offset += localHeader.length + nameBytes.length + data.length
84
+ }
85
+
86
+ const centralSize = central.reduce((sum, chunk) => sum + chunk.length, 0)
87
+ const end = Buffer.alloc(22)
88
+ end.writeUInt32LE(0x06054b50, 0)
89
+ end.writeUInt16LE(0, 4)
90
+ end.writeUInt16LE(0, 6)
91
+ end.writeUInt16LE(allowedFiles.length, 8)
92
+ end.writeUInt16LE(allowedFiles.length, 10)
93
+ end.writeUInt32LE(centralSize, 12)
94
+ end.writeUInt32LE(offset, 16)
95
+ end.writeUInt16LE(0, 20)
96
+
97
+ await new Promise((resolvePromise, reject) => {
98
+ const stream = createWriteStream(zipPath)
99
+ stream.on("error", reject)
100
+ stream.on("finish", resolvePromise)
101
+ for (const chunk of [...chunks, ...central, end]) stream.write(chunk)
102
+ stream.end()
103
+ })
104
+ }
105
+
106
+ function crc32(buffer) {
107
+ let crc = 0xffffffff
108
+ for (const byte of buffer) {
109
+ crc ^= byte
110
+ for (let i = 0; i < 8; i += 1) {
111
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
112
+ }
113
+ }
114
+ return (crc ^ 0xffffffff) >>> 0
115
+ }
116
+
117
+ await mkdir(distDir, { recursive: true })
118
+ await rm(zipPath, { force: true })
119
+
120
+ try {
121
+ await zipWithSystemZip()
122
+ } catch {
123
+ await writeMinimalZip()
124
+ }
125
+
126
+ const { stat, readFile } = await import("node:fs/promises")
127
+ const data = await readFile(zipPath)
128
+ const info = {
129
+ ok: true,
130
+ zipPath,
131
+ version,
132
+ files: allowedFiles,
133
+ sizeBytes: (await stat(zipPath)).size,
134
+ sha256: createHash("sha256").update(data).digest("hex"),
135
+ }
136
+
137
+ console.log(JSON.stringify(info, null, 2))