@small-protocol/small 1.0.4
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 +17 -0
- package/bin/small.js +23 -0
- package/package.json +29 -0
- package/scripts/postinstall.js +239 -0
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @small-protocol/small
|
|
2
|
+
|
|
3
|
+
This package installs the native SMALL binary for your platform and exposes it as `small`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -g @small-protocol/small
|
|
9
|
+
small --version
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Behavior
|
|
13
|
+
|
|
14
|
+
- Maps npm version `X.Y.Z` to GitHub release tag `vX.Y.Z`
|
|
15
|
+
- Downloads `checksums.txt` and platform archive from GitHub Releases
|
|
16
|
+
- Verifies SHA256 before extracting
|
|
17
|
+
- Stores the binary in `vendor/small`
|
package/bin/small.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { spawnSync } = require("node:child_process");
|
|
6
|
+
|
|
7
|
+
const binaryPath = path.resolve(__dirname, "..", "vendor", "small");
|
|
8
|
+
|
|
9
|
+
if (!fs.existsSync(binaryPath)) {
|
|
10
|
+
console.error("SMALL binary missing. Reinstall or run npm rebuild @small-protocol/small");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const result = spawnSync(binaryPath, process.argv.slice(2), {
|
|
15
|
+
stdio: "inherit"
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (result.error) {
|
|
19
|
+
console.error(`Failed to launch SMALL: ${result.error.message}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@small-protocol/small",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "npm wrapper for SMALL CLI",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/justyn-clark/small-protocol.git",
|
|
9
|
+
"directory": "packages/npm"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"small": "bin/small.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"postinstall": "node scripts/postinstall.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"scripts",
|
|
20
|
+
"vendor",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const https = require("node:https");
|
|
7
|
+
const crypto = require("node:crypto");
|
|
8
|
+
const { spawnSync } = require("node:child_process");
|
|
9
|
+
|
|
10
|
+
const REPO = "justyn-clark/small-protocol";
|
|
11
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
12
|
+
const VENDOR_DIR = path.join(ROOT, "vendor");
|
|
13
|
+
const VENDOR_BIN = path.join(VENDOR_DIR, "small");
|
|
14
|
+
|
|
15
|
+
function resolvePlatform() {
|
|
16
|
+
const platformMap = {
|
|
17
|
+
darwin: "darwin",
|
|
18
|
+
linux: "linux"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const archMap = {
|
|
22
|
+
x64: "amd64",
|
|
23
|
+
arm64: "arm64"
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const osName = platformMap[process.platform];
|
|
27
|
+
const archName = archMap[process.arch];
|
|
28
|
+
|
|
29
|
+
if (!osName) {
|
|
30
|
+
throw new Error(`Unsupported OS for SMALL npm package: ${process.platform}`);
|
|
31
|
+
}
|
|
32
|
+
if (!archName) {
|
|
33
|
+
throw new Error(`Unsupported architecture for SMALL npm package: ${process.arch}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { osName, archName };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readPackageVersion() {
|
|
40
|
+
const pkgPath = path.join(ROOT, "package.json");
|
|
41
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
42
|
+
if (!pkg.version) {
|
|
43
|
+
throw new Error("package.json is missing version");
|
|
44
|
+
}
|
|
45
|
+
return pkg.version;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function request(url, expectJson, redirects = 0, authOrigin = "") {
|
|
49
|
+
const parsed = new URL(url);
|
|
50
|
+
const headers = {
|
|
51
|
+
"User-Agent": "small-npm-installer",
|
|
52
|
+
Accept: expectJson ? "application/vnd.github+json" : "*/*"
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const token = process.env.GITHUB_TOKEN;
|
|
56
|
+
const resolvedAuthOrigin = authOrigin || (token ? parsed.origin : "");
|
|
57
|
+
if (token && resolvedAuthOrigin && parsed.origin === resolvedAuthOrigin) {
|
|
58
|
+
headers.Authorization = `Bearer ${token}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const req = https.get(url, { headers }, (res) => {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
65
|
+
res.on("end", async () => {
|
|
66
|
+
const body = Buffer.concat(chunks);
|
|
67
|
+
|
|
68
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
69
|
+
if (redirects >= 5) {
|
|
70
|
+
reject(new Error(`Too many redirects while fetching ${url}`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const nextUrl = new URL(res.headers.location, url).toString();
|
|
75
|
+
const nextOrigin = new URL(nextUrl).origin;
|
|
76
|
+
const forwardedAuthOrigin = nextOrigin === resolvedAuthOrigin ? resolvedAuthOrigin : "";
|
|
77
|
+
const redirected = await request(nextUrl, expectJson, redirects + 1, forwardedAuthOrigin);
|
|
78
|
+
resolve(redirected);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
reject(err);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
86
|
+
reject(new Error(`Request failed ${res.statusCode} for ${url}: ${body.toString("utf8")}`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!expectJson) {
|
|
91
|
+
resolve(body);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
resolve(JSON.parse(body.toString("utf8")));
|
|
97
|
+
} catch (err) {
|
|
98
|
+
reject(new Error(`Invalid JSON from ${url}: ${err.message}`));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
req.on("error", reject);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function checksumForAsset(checksumsText, assetName) {
|
|
108
|
+
const lines = checksumsText.split(/\r?\n/);
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const match = line.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
|
111
|
+
if (match && match[2] === assetName) {
|
|
112
|
+
return match[1].toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function findBinary(startDir) {
|
|
119
|
+
const entries = fs.readdirSync(startDir, { withFileTypes: true });
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const full = path.join(startDir, entry.name);
|
|
122
|
+
if (entry.isFile() && entry.name === "small") {
|
|
123
|
+
return full;
|
|
124
|
+
}
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
const nested = findBinary(full);
|
|
127
|
+
if (nested) {
|
|
128
|
+
return nested;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateTarEntries(tarballPath) {
|
|
136
|
+
const listing = spawnSync("tar", ["-tzf", tarballPath], {
|
|
137
|
+
encoding: "utf8"
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (listing.status !== 0) {
|
|
141
|
+
throw new Error("Failed to list archive entries");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const entries = listing.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
const stripped = entry.replace(/^\.\/+/, "");
|
|
147
|
+
if (!stripped) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const normalized = path.posix.normalize(stripped);
|
|
151
|
+
if (normalized === ".." || normalized.startsWith("../") || path.posix.isAbsolute(stripped) || normalized.includes("/../")) {
|
|
152
|
+
throw new Error(`Archive contains unsafe path entry: ${entry}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function main() {
|
|
158
|
+
const version = readPackageVersion();
|
|
159
|
+
const tag = `v${version}`;
|
|
160
|
+
const { osName, archName } = resolvePlatform();
|
|
161
|
+
let assetName = `small-${tag}-${osName}-${archName}.tar.gz`;
|
|
162
|
+
|
|
163
|
+
const releaseUrl = `https://api.github.com/repos/${REPO}/releases/tags/${tag}`;
|
|
164
|
+
const release = await request(releaseUrl, true);
|
|
165
|
+
|
|
166
|
+
let asset = (release.assets || []).find((item) => item.name === assetName);
|
|
167
|
+
|
|
168
|
+
// Fallback for older naming convention (v1.0.2 and earlier)
|
|
169
|
+
if (!asset) {
|
|
170
|
+
const osOld = osName.charAt(0).toUpperCase() + osName.slice(1);
|
|
171
|
+
const archOld = archName === "amd64" ? "x86_64" : archName;
|
|
172
|
+
const tagOld = tag.startsWith("v") ? tag.slice(1) : tag;
|
|
173
|
+
const assetNameOld = `small-protocol_${tagOld}_${osOld}_${archOld}.tar.gz`;
|
|
174
|
+
asset = (release.assets || []).find((item) => item.name === assetNameOld);
|
|
175
|
+
if (asset) {
|
|
176
|
+
console.log(`[small npm] Using legacy asset pattern fallback: ${assetNameOld}`);
|
|
177
|
+
assetName = assetNameOld;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!asset) {
|
|
182
|
+
throw new Error(`Release ${tag} is missing asset ${assetName}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const checksumsAsset = (release.assets || []).find((item) => item.name === "checksums.txt");
|
|
186
|
+
if (!checksumsAsset) {
|
|
187
|
+
throw new Error(`Release ${tag} is missing checksums.txt`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "small-npm-"));
|
|
191
|
+
const tarballPath = path.join(tempRoot, assetName);
|
|
192
|
+
const checksumsPath = path.join(tempRoot, "checksums.txt");
|
|
193
|
+
const extractDir = path.join(tempRoot, "extract");
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const tarballBody = await request(asset.browser_download_url, false);
|
|
197
|
+
fs.writeFileSync(tarballPath, tarballBody);
|
|
198
|
+
|
|
199
|
+
const checksumsBody = await request(checksumsAsset.browser_download_url, false);
|
|
200
|
+
fs.writeFileSync(checksumsPath, checksumsBody);
|
|
201
|
+
|
|
202
|
+
const expected = checksumForAsset(fs.readFileSync(checksumsPath, "utf8"), assetName);
|
|
203
|
+
if (!expected) {
|
|
204
|
+
throw new Error(`checksums.txt does not contain an entry for ${assetName}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const actual = crypto.createHash("sha256").update(fs.readFileSync(tarballPath)).digest("hex");
|
|
208
|
+
if (expected !== actual) {
|
|
209
|
+
throw new Error(`Checksum mismatch for ${assetName}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
validateTarEntries(tarballPath);
|
|
213
|
+
|
|
214
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
215
|
+
const untar = spawnSync("tar", ["-xzf", tarballPath, "--no-same-owner", "-C", extractDir], {
|
|
216
|
+
stdio: "inherit"
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (untar.status !== 0) {
|
|
220
|
+
throw new Error("Failed to extract SMALL archive");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const binary = findBinary(extractDir);
|
|
224
|
+
if (!binary) {
|
|
225
|
+
throw new Error("Extracted archive did not contain a small binary");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fs.mkdirSync(VENDOR_DIR, { recursive: true });
|
|
229
|
+
fs.copyFileSync(binary, VENDOR_BIN);
|
|
230
|
+
fs.chmodSync(VENDOR_BIN, 0o755);
|
|
231
|
+
} finally {
|
|
232
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
main().catch((err) => {
|
|
237
|
+
console.error(`[small npm] ${err.message}`);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|