@xberg-io/ts-pack-cli 0.0.1
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/bin/ts-pack.js +74 -0
- package/install.js +422 -0
- package/package.json +26 -0
package/bin/ts-pack.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launcher: exec the downloaded native ts-pack binary, forwarding argv and
|
|
3
|
+
// inheriting stdio. If the binary is missing (postinstall failed), download it
|
|
4
|
+
// on demand before exec.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
const BIN_NAME = "ts-pack";
|
|
12
|
+
|
|
13
|
+
function binaryName() {
|
|
14
|
+
return os.type() === "Windows_NT" ? `${BIN_NAME}.exe` : BIN_NAME;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
// install.js extracts the binary into this same bin/ directory.
|
|
19
|
+
const binPath = path.join(__dirname, binaryName());
|
|
20
|
+
|
|
21
|
+
// A cached binary is only usable if it is non-empty and (on non-Windows) has an
|
|
22
|
+
// exec bit. A truncated or non-executable file means a corrupt cache: re-download.
|
|
23
|
+
function isHealthy(file) {
|
|
24
|
+
try {
|
|
25
|
+
const stat = fs.statSync(file);
|
|
26
|
+
if (stat.size <= 0) return false;
|
|
27
|
+
if (os.type() !== "Windows_NT" && (stat.mode & 0o111) === 0) return false;
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function ensureBinary() {
|
|
35
|
+
if (fs.existsSync(binPath) && isHealthy(binPath)) return;
|
|
36
|
+
process.stderr.write(`${BIN_NAME}: binary missing or corrupt, attempting download...\n`);
|
|
37
|
+
// Call main() explicitly rather than relying on import side-effects: ESM
|
|
38
|
+
// caches modules, so the installer's top-level run is gated to direct
|
|
39
|
+
// invocation only and would not fire on import.
|
|
40
|
+
const { main: runInstaller } = await import("../install.js");
|
|
41
|
+
await runInstaller();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function printUnavailable() {
|
|
45
|
+
process.stderr.write(
|
|
46
|
+
`${BIN_NAME} is not available for your platform yet. Install it with:\n` +
|
|
47
|
+
` brew install xberg-io/tap/ts-pack\n` +
|
|
48
|
+
` or use the Xberg plugin: /plugin marketplace add xberg-io/plugins\n`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function main() {
|
|
53
|
+
await ensureBinary();
|
|
54
|
+
if (!fs.existsSync(binPath) || !isHealthy(binPath)) {
|
|
55
|
+
printUnavailable();
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" });
|
|
59
|
+
if (result.error) {
|
|
60
|
+
process.stderr.write(`${BIN_NAME}: failed to spawn binary: ${result.error.message}\n`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
process.exit(result.status ?? 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main().catch((err) => {
|
|
67
|
+
// No standalone CLI for this platform: print the graceful install hint, not a stack.
|
|
68
|
+
if (err && err.name === "CliUnavailableError") {
|
|
69
|
+
printUnavailable();
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
process.stderr.write(`${BIN_NAME}: ${err.message}\n`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
package/install.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// postinstall: download, verify, and extract the native ts-pack binary
|
|
2
|
+
// into ./bin so the launcher can exec it. All diagnostics go to stderr.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import https from "node:https";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { spawnSync, execFileSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
const REPO = "xberg-io/tree-sitter-language-pack";
|
|
12
|
+
const BIN_NAME = "ts-pack";
|
|
13
|
+
const PKG_NAME = "ts-pack-cli";
|
|
14
|
+
const VERSION_ENV = "TS_PACK_CLI_VERSION";
|
|
15
|
+
const USER_AGENT = "ts-pack-cli-npm-proxy";
|
|
16
|
+
|
|
17
|
+
// Map Node's platform/arch to the Rust target triple embedded in asset names.
|
|
18
|
+
function targetTriple() {
|
|
19
|
+
const type = os.type();
|
|
20
|
+
const arch = os.arch();
|
|
21
|
+
|
|
22
|
+
if (type === "Windows_NT") {
|
|
23
|
+
if (arch === "x64") return "x86_64-pc-windows-msvc";
|
|
24
|
+
throw new Error(`unsupported Windows arch: ${arch}`);
|
|
25
|
+
}
|
|
26
|
+
if (type === "Linux") {
|
|
27
|
+
if (arch === "x64") return "x86_64-unknown-linux-gnu";
|
|
28
|
+
if (arch === "arm64") return "aarch64-unknown-linux-gnu";
|
|
29
|
+
throw new Error(`unsupported Linux arch: ${arch}`);
|
|
30
|
+
}
|
|
31
|
+
if (type === "Darwin") {
|
|
32
|
+
if (arch === "arm64") return "aarch64-apple-darwin";
|
|
33
|
+
if (arch === "x64") return "x86_64-apple-darwin";
|
|
34
|
+
throw new Error(`unsupported macOS arch: ${arch}`);
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`unsupported platform: ${type} ${arch}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function binaryName() {
|
|
40
|
+
return os.type() === "Windows_NT" ? `${BIN_NAME}.exe` : BIN_NAME;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// GET a URL following redirects, returning the response body as a Buffer.
|
|
44
|
+
// Every hop (initial request and every redirect target) MUST be https; any
|
|
45
|
+
// other scheme is rejected to prevent downgrade/SSRF via a malicious Location.
|
|
46
|
+
function httpGetBuffer(url, { headers = {} } = {}, maxRedirects = 5) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
if (maxRedirects < 0) return reject(new Error("too many redirects"));
|
|
49
|
+
if (!/^https:\/\//i.test(url)) {
|
|
50
|
+
return reject(new Error(`refusing non-https URL: ${url}`));
|
|
51
|
+
}
|
|
52
|
+
const req = https.get(url, { headers: { "User-Agent": USER_AGENT, ...headers } }, (res) => {
|
|
53
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
54
|
+
res.resume();
|
|
55
|
+
const next = res.headers.location;
|
|
56
|
+
if (!/^https:\/\//i.test(next)) {
|
|
57
|
+
return reject(new Error(`refusing non-https redirect to: ${next}`));
|
|
58
|
+
}
|
|
59
|
+
return httpGetBuffer(next, { headers }, maxRedirects - 1).then(resolve, reject);
|
|
60
|
+
}
|
|
61
|
+
if (res.statusCode !== 200) {
|
|
62
|
+
res.resume();
|
|
63
|
+
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
64
|
+
}
|
|
65
|
+
const chunks = [];
|
|
66
|
+
res.on("data", (c) => chunks.push(c));
|
|
67
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
68
|
+
res.on("error", reject);
|
|
69
|
+
});
|
|
70
|
+
req.on("error", reject);
|
|
71
|
+
req.setTimeout(60000, () => {
|
|
72
|
+
req.destroy();
|
|
73
|
+
reject(new Error(`timeout for ${url}`));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function httpGetJson(url) {
|
|
79
|
+
const buf = await httpGetBuffer(url, { headers: { Accept: "application/vnd.github+json" } });
|
|
80
|
+
return JSON.parse(buf.toString("utf8"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Signals that the release carries no standalone CLI for this platform (only
|
|
84
|
+
// bindings/native-lib/brew-bottle artifacts). The launcher catches this by name
|
|
85
|
+
// and prints a graceful install hint instead of a raw stack trace.
|
|
86
|
+
export class CliUnavailableError extends Error {
|
|
87
|
+
constructor(message) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = "CliUnavailableError";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Substrings that mark an asset as a binding/native-lib/brew-bottle artifact —
|
|
94
|
+
// never the standalone CLI. Matched case-insensitively anywhere in the name.
|
|
95
|
+
const NON_CLI_PATTERNS = [
|
|
96
|
+
"-ffi",
|
|
97
|
+
"_ffi",
|
|
98
|
+
"ffi-",
|
|
99
|
+
"nif",
|
|
100
|
+
".so",
|
|
101
|
+
".dylib",
|
|
102
|
+
".dll",
|
|
103
|
+
"artifactbundle",
|
|
104
|
+
".bottle.",
|
|
105
|
+
"bottle",
|
|
106
|
+
"node",
|
|
107
|
+
"wheel",
|
|
108
|
+
".whl",
|
|
109
|
+
"napi",
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
// True if the asset name matches any non-CLI artifact pattern.
|
|
113
|
+
export function isNonCliArtifact(name) {
|
|
114
|
+
const n = (name || "").toLowerCase();
|
|
115
|
+
return NON_CLI_PATTERNS.some((pat) => n.includes(pat));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function assetScore(name) {
|
|
119
|
+
const n = (name || "").toLowerCase();
|
|
120
|
+
let score = 0;
|
|
121
|
+
if (n.includes("cli")) score += 2;
|
|
122
|
+
if (n.includes(BIN_NAME.toLowerCase())) score += 1;
|
|
123
|
+
return score;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Pure asset-selection core: from a list of asset names, keep only triple-matched
|
|
127
|
+
// .tar.gz/.zip archives that are NOT binding/native-lib/bottle artifacts, then
|
|
128
|
+
// return the best (cli/bin-name preferred). Returns null when none qualify.
|
|
129
|
+
export function selectArchiveName(names, triple) {
|
|
130
|
+
const survivors = (names || []).filter((name) => {
|
|
131
|
+
const n = (name || "").toLowerCase();
|
|
132
|
+
if (!n.includes(triple)) return false;
|
|
133
|
+
if (!(n.endsWith(".tar.gz") || n.endsWith(".zip"))) return false;
|
|
134
|
+
return !isNonCliArtifact(n);
|
|
135
|
+
});
|
|
136
|
+
if (survivors.length === 0) return null;
|
|
137
|
+
survivors.sort((a, b) => assetScore(b) - assetScore(a));
|
|
138
|
+
return survivors[0];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Resolve the release (honoring TS_PACK_CLI_VERSION to pin a tag) and pick the
|
|
142
|
+
// archive asset for this platform plus an optional SHA256SUMS asset.
|
|
143
|
+
//
|
|
144
|
+
// Selection: among assets whose name contains the target triple, ends in
|
|
145
|
+
// .tar.gz/.zip, and is NOT a binding/native-lib/bottle artifact, prefer one
|
|
146
|
+
// whose name contains "cli" or the bin name. If none survive, the release has
|
|
147
|
+
// no standalone CLI for this platform (CliUnavailableError).
|
|
148
|
+
async function resolveRelease() {
|
|
149
|
+
const triple = targetTriple();
|
|
150
|
+
const pinned = process.env[VERSION_ENV];
|
|
151
|
+
const apiUrl = pinned
|
|
152
|
+
? `https://api.github.com/repos/${REPO}/releases/tags/${encodeURIComponent(pinned)}`
|
|
153
|
+
: `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
154
|
+
|
|
155
|
+
let release;
|
|
156
|
+
try {
|
|
157
|
+
release = await httpGetJson(apiUrl);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (pinned && /HTTP 404/.test(err.message)) {
|
|
160
|
+
throw new Error(`release tag '${pinned}' not found`, { cause: err });
|
|
161
|
+
}
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
const assets = Array.isArray(release.assets) ? release.assets : [];
|
|
165
|
+
const tag = release.tag_name || pinned || "latest";
|
|
166
|
+
|
|
167
|
+
const chosenName = selectArchiveName(
|
|
168
|
+
assets.map((a) => a.name),
|
|
169
|
+
triple,
|
|
170
|
+
);
|
|
171
|
+
if (!chosenName) {
|
|
172
|
+
throw new CliUnavailableError(
|
|
173
|
+
`no standalone CLI asset for target triple "${triple}" in ${REPO} release ${tag}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const archive = assets.find((a) => a.name === chosenName);
|
|
177
|
+
const checksums = assets.find((a) => (a.name || "").toUpperCase().includes("SHA256SUMS"));
|
|
178
|
+
|
|
179
|
+
return { tag, triple, archive, checksums };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
183
|
+
const BIN_DIR = path.join(__dirname, "bin");
|
|
184
|
+
|
|
185
|
+
// Parse a `sha256<space>filename` checksums file; return the digest for name.
|
|
186
|
+
function expectedDigest(text, assetName) {
|
|
187
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
188
|
+
const line = raw.trim();
|
|
189
|
+
if (!line) continue;
|
|
190
|
+
const parts = line.split(/\s+/);
|
|
191
|
+
if (parts.length < 2) continue;
|
|
192
|
+
const name = parts[parts.length - 1].replace(/^\*/, "");
|
|
193
|
+
if (name === assetName) return parts[0].toLowerCase();
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function verifyOrWarn(archiveBuf, archiveName, checksums) {
|
|
199
|
+
if (!checksums) {
|
|
200
|
+
process.stderr.write(
|
|
201
|
+
`WARNING: no SHA256SUMS asset found for ${archiveName}; ` +
|
|
202
|
+
`installing over HTTPS without checksum verification.\n`,
|
|
203
|
+
);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const sumsText = (await httpGetBuffer(checksums.browser_download_url)).toString("utf8");
|
|
207
|
+
const expected = expectedDigest(sumsText, archiveName);
|
|
208
|
+
if (!expected) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`no checksum entry for ${archiveName} in ${checksums.name} — refusing to install unverified binary`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const actual = crypto.createHash("sha256").update(archiveBuf).digest("hex").toLowerCase();
|
|
214
|
+
if (actual !== expected) {
|
|
215
|
+
throw new Error(`checksum mismatch for ${archiveName} (expected ${expected}, got ${actual})`);
|
|
216
|
+
}
|
|
217
|
+
process.stderr.write(`Checksum verified for ${archiveName}.\n`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Reject archive entry names that could escape the extraction directory:
|
|
221
|
+
// absolute paths (POSIX or Windows drive/UNC) or any component equal to "..".
|
|
222
|
+
function isUnsafeEntry(name) {
|
|
223
|
+
const entry = String(name).replace(/\\/g, "/").trim();
|
|
224
|
+
if (!entry) return false;
|
|
225
|
+
if (entry.startsWith("/")) return true;
|
|
226
|
+
if (/^[a-zA-Z]:/.test(entry)) return true; // Windows drive letter
|
|
227
|
+
if (entry.startsWith("//")) return true; // UNC
|
|
228
|
+
return entry.split("/").some((part) => part === "..");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// List the entries of a gzipped tar without extracting (`tar -tzf`).
|
|
232
|
+
function listTarEntries(archivePath) {
|
|
233
|
+
const result = spawnSync("tar", ["-tzf", archivePath]);
|
|
234
|
+
if (result.status !== 0) {
|
|
235
|
+
const stderr = result.stderr ? result.stderr.toString() : "";
|
|
236
|
+
throw new Error(`tar listing failed: ${stderr || result.error}`);
|
|
237
|
+
}
|
|
238
|
+
return result.stdout
|
|
239
|
+
.toString()
|
|
240
|
+
.split(/\r?\n/)
|
|
241
|
+
.map((s) => s.trim())
|
|
242
|
+
.filter(Boolean);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function extractTarGz(archivePath, destDir) {
|
|
246
|
+
const result = spawnSync("tar", ["-xzf", archivePath, "-C", destDir]);
|
|
247
|
+
if (result.status !== 0) {
|
|
248
|
+
const stderr = result.stderr ? result.stderr.toString() : "";
|
|
249
|
+
throw new Error(`tar extraction failed: ${stderr || result.error}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// List the entries of a zip without extracting (`unzip -Z1`, or PowerShell on Windows).
|
|
254
|
+
function listZipEntries(archivePath) {
|
|
255
|
+
if (os.type() === "Windows_NT") {
|
|
256
|
+
const script =
|
|
257
|
+
"$ErrorActionPreference='Stop';" +
|
|
258
|
+
"Add-Type -AssemblyName System.IO.Compression.FileSystem;" +
|
|
259
|
+
"[System.IO.Compression.ZipFile]::OpenRead($args[0]).Entries |" +
|
|
260
|
+
" ForEach-Object { $_.FullName }";
|
|
261
|
+
const out = execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script, archivePath], {
|
|
262
|
+
encoding: "utf8",
|
|
263
|
+
});
|
|
264
|
+
return out
|
|
265
|
+
.split(/\r?\n/)
|
|
266
|
+
.map((s) => s.trim())
|
|
267
|
+
.filter(Boolean);
|
|
268
|
+
}
|
|
269
|
+
const result = spawnSync("unzip", ["-Z1", archivePath]);
|
|
270
|
+
if (result.status !== 0) {
|
|
271
|
+
const stderr = result.stderr ? result.stderr.toString() : "";
|
|
272
|
+
throw new Error(`zip listing failed: ${stderr || result.error}`);
|
|
273
|
+
}
|
|
274
|
+
return result.stdout
|
|
275
|
+
.toString()
|
|
276
|
+
.split(/\r?\n/)
|
|
277
|
+
.map((s) => s.trim())
|
|
278
|
+
.filter(Boolean);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function extractZip(archivePath, destDir) {
|
|
282
|
+
if (os.type() === "Windows_NT") {
|
|
283
|
+
// No string interpolation into a -Command: all path data passed as literal args.
|
|
284
|
+
const result = spawnSync("powershell", [
|
|
285
|
+
"-NoProfile",
|
|
286
|
+
"-NonInteractive",
|
|
287
|
+
"-Command",
|
|
288
|
+
"Expand-Archive",
|
|
289
|
+
"-LiteralPath",
|
|
290
|
+
archivePath,
|
|
291
|
+
"-DestinationPath",
|
|
292
|
+
destDir,
|
|
293
|
+
"-Force",
|
|
294
|
+
]);
|
|
295
|
+
if (result.status !== 0) {
|
|
296
|
+
const stderr = result.stderr ? result.stderr.toString() : "";
|
|
297
|
+
throw new Error(`zip extraction failed: ${stderr || result.error}`);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const result = spawnSync("unzip", ["-o", archivePath, "-d", destDir]);
|
|
302
|
+
if (result.status !== 0) {
|
|
303
|
+
const stderr = result.stderr ? result.stderr.toString() : "";
|
|
304
|
+
throw new Error(`zip extraction failed: ${stderr || result.error}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Locate the binary anywhere under dir (archives may nest it in a subdir).
|
|
309
|
+
function findBinary(dir, name) {
|
|
310
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
311
|
+
const full = path.join(dir, entry.name);
|
|
312
|
+
if (entry.isDirectory()) {
|
|
313
|
+
const found = findBinary(full, name);
|
|
314
|
+
if (found) return found;
|
|
315
|
+
} else if (entry.name === name) {
|
|
316
|
+
return full;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Find a directory named `name` anywhere under dir.
|
|
323
|
+
function findDir(dir, name) {
|
|
324
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
325
|
+
if (!entry.isDirectory()) continue;
|
|
326
|
+
if (entry.name === name) return path.join(dir, entry.name);
|
|
327
|
+
const found = findDir(path.join(dir, entry.name), name);
|
|
328
|
+
if (found) return found;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Validate every archive entry, extract into an isolated temp dir, then copy
|
|
334
|
+
// out ONLY the expected binary (by basename) plus an optional sibling lib/ dir.
|
|
335
|
+
// Nothing else from the archive is honored, so a malicious member can never
|
|
336
|
+
// land outside dest even if extraction tooling mishandled it.
|
|
337
|
+
function safeExtract(archivePath, archiveName, dest) {
|
|
338
|
+
const isZip = archiveName.toLowerCase().endsWith(".zip");
|
|
339
|
+
const entries = isZip ? listZipEntries(archivePath) : listTarEntries(archivePath);
|
|
340
|
+
for (const entry of entries) {
|
|
341
|
+
if (isUnsafeEntry(entry)) {
|
|
342
|
+
throw new Error(`refusing unsafe archive entry: ${entry}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `${PKG_NAME}-x-`));
|
|
347
|
+
try {
|
|
348
|
+
if (isZip) {
|
|
349
|
+
extractZip(archivePath, tmpDir);
|
|
350
|
+
} else {
|
|
351
|
+
extractTarGz(archivePath, tmpDir);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const binName = binaryName();
|
|
355
|
+
const extractedBin = findBinary(tmpDir, binName);
|
|
356
|
+
if (!extractedBin) {
|
|
357
|
+
// The chosen asset did not actually contain the CLI binary — treat the
|
|
358
|
+
// release as having no valid CLI for this platform rather than leaving a
|
|
359
|
+
// bad/partial install behind.
|
|
360
|
+
throw new CliUnavailableError(`archive ${archiveName} did not contain expected CLI binary ${binName}`);
|
|
361
|
+
}
|
|
362
|
+
const finalBin = path.join(dest, binName);
|
|
363
|
+
fs.copyFileSync(extractedBin, finalBin);
|
|
364
|
+
|
|
365
|
+
// Copy a sibling lib/ directory if present (some platforms ship shared libs).
|
|
366
|
+
const libDir = findDir(tmpDir, "lib");
|
|
367
|
+
if (libDir) {
|
|
368
|
+
fs.cpSync(libDir, path.join(dest, "lib"), { recursive: true });
|
|
369
|
+
}
|
|
370
|
+
return finalBin;
|
|
371
|
+
} finally {
|
|
372
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function main() {
|
|
377
|
+
const binName = binaryName();
|
|
378
|
+
const finalPath = path.join(BIN_DIR, binName);
|
|
379
|
+
if (fs.existsSync(finalPath)) {
|
|
380
|
+
try {
|
|
381
|
+
const stat = fs.statSync(finalPath);
|
|
382
|
+
const sizeOk = stat.size > 0;
|
|
383
|
+
const execOk = os.type() === "Windows_NT" || (stat.mode & 0o111) !== 0;
|
|
384
|
+
if (sizeOk && execOk) return;
|
|
385
|
+
} catch {
|
|
386
|
+
// fall through and re-download
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
391
|
+
|
|
392
|
+
const { tag, archive, checksums } = await resolveRelease();
|
|
393
|
+
process.stderr.write(`Downloading ${BIN_NAME} ${tag} asset ${archive.name}...\n`);
|
|
394
|
+
|
|
395
|
+
const archiveBuf = await httpGetBuffer(archive.browser_download_url);
|
|
396
|
+
await verifyOrWarn(archiveBuf, archive.name, checksums);
|
|
397
|
+
|
|
398
|
+
// Stage the archive in an isolated temp dir; never extract straight into BIN_DIR.
|
|
399
|
+
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), `${PKG_NAME}-dl-`));
|
|
400
|
+
try {
|
|
401
|
+
const archivePath = path.join(stageDir, path.basename(archive.name));
|
|
402
|
+
fs.writeFileSync(archivePath, archiveBuf);
|
|
403
|
+
safeExtract(archivePath, archive.name, BIN_DIR);
|
|
404
|
+
} finally {
|
|
405
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (os.type() !== "Windows_NT") {
|
|
409
|
+
fs.chmodSync(finalPath, 0o755);
|
|
410
|
+
}
|
|
411
|
+
process.stderr.write(`${BIN_NAME} installed.\n`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Run automatically only when invoked directly (npm postinstall: `node install.js`).
|
|
415
|
+
// When imported by the launcher, the launcher calls main() explicitly instead of
|
|
416
|
+
// relying on import side-effects (ESM caches modules, so a second import is a no-op).
|
|
417
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
|
418
|
+
main().catch((err) => {
|
|
419
|
+
process.stderr.write(`Error installing ${BIN_NAME}: ${err.message}\n`);
|
|
420
|
+
process.exit(1);
|
|
421
|
+
});
|
|
422
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xberg-io/ts-pack-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "CLI proxy for ts-pack — downloads and runs the native ts-pack binary from GitHub releases.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Na'aman Hirschfeld",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/xberg-io/tree-sitter-language-pack.git",
|
|
10
|
+
"directory": "cli-proxy/npm"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"ts-pack": "bin/ts-pack.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"install.js"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
}
|
|
26
|
+
}
|