@zlnap/astrolabe 0.0.0-stage → 0.17.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 CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # @zlnap/astrolabe
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Astrolabe AI:面向 AI 编码助手的本地代码检索与导航 MCP server(二进制名 `astrolabe`)。与 npm 上无关的 `astrolabe`、`astrolabe-ai` 包不是同一个项目。本包是**零依赖**的 Node shim:通过 `optionalDependencies` 安装当前平台的原生二进制,再把 stdin/stdout/stderr、退出码和信号原样交给它。
4
+
5
+ ```bash
6
+ npx -y @zlnap/astrolabe /path/to/repo
7
+ ```
8
+
9
+ MCP 客户端(推荐一键配置):
10
+
11
+ ```bash
12
+ npx -y @zlnap/astrolabe setup
13
+ ```
14
+
15
+ Detects Claude Code, Cursor, Codex, Windsurf, Cline, Roo, Continue, OpenCode, DeepSeek Harness, Codebuddy, Grok Build, Kimi Code, and ZCode. Merges MCP config (OpenCode V1/V2/jsonc; Grok `[mcp_servers.*]` TOML; ZCode `mcp.servers`; DSH installs the native guard plugin + `cordis.patch.yml` merge). Optional `--with-hooks` installs OpenCode's guard plugin and hook recipes for Codebuddy / Grok / Kimi / ZCode (ZCode: no `SessionEnd`, do not hang cleanup on `Stop`). Flags: `--yes`, `--clients=…`, `--dry-run`, `--with-hooks`, `--list`.
16
+
17
+ 探测本机 Claude Code / Cursor / Codex 等,交互勾选后合并写入 MCP 配置(`--yes`、`--dry-run`、`--list` 可用)。手动配置示例:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "astrolabe": {
23
+ "command": "npx",
24
+ "args": ["-y", "@zlnap/astrolabe", ".", "--context=cursor"]
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ 日志走 stderr,JSON-RPC 走 stdin/stdout,shim 不会缓冲或改写任何通道。
31
+
32
+ 若平台包因 `--omit=optional`、`--ignore-scripts` 或 npm 11 以下的 lockfile bug 缺失,shim 会从 GitHub Releases 下载对应 target 的归档,校验 SHA256 后缓存到 `~/.astrolabe/bin/`。
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const argv = process.argv.slice(2);
5
+
6
+ // `astrolabe setup` is implemented in Node (detect agents + merge MCP configs).
7
+ // Intercept before resolving / spawning the native binary.
8
+ if (argv[0] === 'setup') {
9
+ const { run } = require('../lib/setup');
10
+ try {
11
+ process.exitCode = run(argv.slice(1));
12
+ } catch (error) {
13
+ console.error(`[astrolabe setup] ${error.message || error}`);
14
+ process.exitCode = 1;
15
+ }
16
+ } else {
17
+ const { resolveBinary } = require('../lib/resolve');
18
+ const { runBinary } = require('../lib/run');
19
+
20
+ resolveBinary()
21
+ .then((binPath) => {
22
+ runBinary(binPath, argv);
23
+ })
24
+ .catch((error) => {
25
+ console.error(`[astrolabe] ${error.message || error}`);
26
+ process.exit(1);
27
+ });
28
+ }
@@ -0,0 +1,355 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const http = require('http');
6
+ const https = require('https');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { spawnSync } = require('child_process');
10
+
11
+ const { archiveName } = require('./platforms');
12
+
13
+ const DEFAULT_HOST = 'https://github.com';
14
+ const DEFAULT_REPO = 'wojiushirencai/Astrolabe';
15
+ const MAX_REDIRECTS = 10;
16
+ const REQUEST_TIMEOUT_MS = 60_000;
17
+
18
+ function cacheRoot() {
19
+ if (process.env.ASTROLABE_CACHE_DIR) {
20
+ return process.env.ASTROLABE_CACHE_DIR;
21
+ }
22
+ return path.join(os.homedir(), '.astrolabe', 'bin');
23
+ }
24
+
25
+ function releaseRepo() {
26
+ return process.env.ASTROLABE_RELEASE_REPO || DEFAULT_REPO;
27
+ }
28
+
29
+ function releaseHost() {
30
+ return (process.env.ASTROLABE_RELEASE_HOST || DEFAULT_HOST).replace(/\/$/, '');
31
+ }
32
+
33
+ /// Human-facing releases page for `version`, used in error messages.
34
+ function releasesTagUrl(version) {
35
+ return `${releaseHost()}/${releaseRepo()}/releases/tag/v${version}`;
36
+ }
37
+
38
+ function releasesBaseUrl(version) {
39
+ if (process.env.ASTROLABE_RELEASES_BASE_URL) {
40
+ return process.env.ASTROLABE_RELEASES_BASE_URL.replace(/\/$/, '');
41
+ }
42
+ return `${releaseHost()}/${releaseRepo()}/releases/download/v${version}`;
43
+ }
44
+
45
+ function requestHeaders(url, isRedirect = false) {
46
+ const headers = {
47
+ Accept: 'application/octet-stream',
48
+ 'User-Agent': `astrolabe-npm/${require('../package.json').version}`,
49
+ };
50
+ if (isRedirect) {
51
+ return headers;
52
+ }
53
+ let host;
54
+ try {
55
+ host = new URL(url).host.toLowerCase();
56
+ } catch {
57
+ return headers;
58
+ }
59
+
60
+ // Never attach credentials to external redirect targets (AWS S3, Aliyun OSS, CDNs)
61
+ if (
62
+ host.endsWith('.amazonaws.com') ||
63
+ host.endsWith('.aliyuncs.com') ||
64
+ host.endsWith('.githubusercontent.com')
65
+ ) {
66
+ return headers;
67
+ }
68
+
69
+ let relHost = '';
70
+ try {
71
+ const raw = process.env.ASTROLABE_RELEASES_BASE_URL || releaseHost();
72
+ relHost = new URL(raw.includes('://') ? raw : `https://${raw}`).host.toLowerCase();
73
+ } catch {
74
+ relHost = '';
75
+ }
76
+
77
+ const isMatchingReleaseHost = relHost && host === relHost;
78
+ const isDirectReleaseEndpoint =
79
+ host === 'github.com' ||
80
+ host === 'api.github.com' ||
81
+ host === 'gitee.com' ||
82
+ host === 'api.gitee.com';
83
+
84
+ if (!isMatchingReleaseHost && !isDirectReleaseEndpoint) {
85
+ return headers;
86
+ }
87
+
88
+ const token = releaseTokenFor(url);
89
+ if (token) {
90
+ headers.Authorization = `Bearer ${token}`;
91
+ }
92
+ return headers;
93
+ }
94
+
95
+ /// Only send a credential to the host it was issued for.
96
+ ///
97
+ /// `GITHUB_TOKEN` is set in most CI environments, and releases now live on
98
+ /// Gitee: attaching it unconditionally would hand a GitHub credential to an
99
+ /// unrelated host on every install. `ASTROLABE_RELEASE_TOKEN` is explicit
100
+ /// and goes to whichever host the user pointed the download at.
101
+ function releaseTokenFor(url) {
102
+ let host;
103
+ try {
104
+ host = new URL(url).host.toLowerCase();
105
+ } catch {
106
+ return undefined;
107
+ }
108
+
109
+ if (
110
+ host.endsWith('.amazonaws.com') ||
111
+ host.endsWith('.aliyuncs.com') ||
112
+ host.endsWith('.githubusercontent.com')
113
+ ) {
114
+ return undefined;
115
+ }
116
+
117
+ if (process.env.ASTROLABE_RELEASE_TOKEN) {
118
+ return process.env.ASTROLABE_RELEASE_TOKEN;
119
+ }
120
+ if (host === 'github.com' || host.endsWith('.github.com')) {
121
+ return process.env.ASTROLABE_GITHUB_TOKEN || process.env.GITHUB_TOKEN;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ function sleep(ms) {
127
+ return new Promise((resolve) => setTimeout(resolve, ms));
128
+ }
129
+
130
+ function downloadToFile(url, dest, redirectsLeft = MAX_REDIRECTS, isRedirect = false) {
131
+ return new Promise((resolve, reject) => {
132
+ const client = url.startsWith('https:') ? https : http;
133
+ const req = client.get(url, { headers: requestHeaders(url, isRedirect) }, (res) => {
134
+ const status = res.statusCode || 0;
135
+ if (status >= 300 && status < 400 && res.headers.location) {
136
+ res.resume();
137
+ if (redirectsLeft <= 0) {
138
+ reject(new Error(`too many redirects fetching ${url}`));
139
+ return;
140
+ }
141
+ const next = new URL(res.headers.location, url).toString();
142
+ downloadToFile(next, dest, redirectsLeft - 1, true).then(resolve, reject);
143
+ return;
144
+ }
145
+ if (status !== 200) {
146
+ res.resume();
147
+ reject(new Error(`HTTP ${status} fetching ${url}`));
148
+ return;
149
+ }
150
+ const out = fs.createWriteStream(dest);
151
+ res.pipe(out);
152
+ out.on('finish', () => out.close(() => resolve(dest)));
153
+ out.on('error', reject);
154
+ });
155
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => {
156
+ req.destroy(new Error(`timed out fetching ${url}`));
157
+ });
158
+ req.on('error', reject);
159
+ });
160
+ }
161
+
162
+ function sha256File(file) {
163
+ return new Promise((resolve, reject) => {
164
+ const hash = crypto.createHash('sha256');
165
+ const stream = fs.createReadStream(file);
166
+ stream.on('data', (chunk) => hash.update(chunk));
167
+ stream.on('end', () => resolve(hash.digest('hex')));
168
+ stream.on('error', reject);
169
+ });
170
+ }
171
+
172
+ function parseSha256Sums(text) {
173
+ const map = new Map();
174
+ for (const rawLine of text.split(/\r?\n/)) {
175
+ const line = rawLine.trim();
176
+ if (!line || line.startsWith('#')) continue;
177
+
178
+ let match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
179
+ if (match) {
180
+ map.set(path.basename(match[2].replace(/^\.\//, '')), match[1].toLowerCase());
181
+ continue;
182
+ }
183
+ match = line.match(/^SHA256 \((.+)\) = ([0-9a-fA-F]{64})$/);
184
+ if (match) {
185
+ map.set(path.basename(match[1]), match[2].toLowerCase());
186
+ }
187
+ }
188
+ return map;
189
+ }
190
+
191
+ function extractArchive(archivePath, destDir) {
192
+ fs.mkdirSync(destDir, { recursive: true });
193
+ const result = spawnSync('tar', ['-xf', archivePath, '-C', destDir], {
194
+ encoding: 'utf8',
195
+ });
196
+ if (result.error || result.status !== 0) {
197
+ if (process.platform === 'win32' && archivePath.endsWith('.zip')) {
198
+ // Pass paths via env vars so PowerShell never interpolates them inside
199
+ // a double-quoted -Command string (paths can contain `$`, `` ` ``, `"`).
200
+ const psResult = spawnSync(
201
+ 'powershell.exe',
202
+ [
203
+ '-NoProfile',
204
+ '-NonInteractive',
205
+ '-Command',
206
+ 'Expand-Archive -LiteralPath $env:ASTROLABE_ZIP_SRC -DestinationPath $env:ASTROLABE_ZIP_DST -Force',
207
+ ],
208
+ {
209
+ encoding: 'utf8',
210
+ env: {
211
+ ...process.env,
212
+ ASTROLABE_ZIP_SRC: archivePath,
213
+ ASTROLABE_ZIP_DST: destDir,
214
+ },
215
+ },
216
+ );
217
+ if (psResult.error) {
218
+ throw new Error(`failed to extract ${archivePath}: ${psResult.error.message}`);
219
+ }
220
+ if (psResult.status !== 0) {
221
+ throw new Error(
222
+ `failed to extract ${archivePath}: ${psResult.stderr || psResult.stdout || `exit ${psResult.status}`}`,
223
+ );
224
+ }
225
+ return;
226
+ }
227
+ if (result.error) {
228
+ throw new Error(`failed to extract ${archivePath}: ${result.error.message}`);
229
+ }
230
+ throw new Error(
231
+ `failed to extract ${archivePath}: ${result.stderr || result.stdout || `exit ${result.status}`}`,
232
+ );
233
+ }
234
+ }
235
+
236
+ function findBinary(dir, binaryName) {
237
+ const direct = path.join(dir, binaryName);
238
+ if (fs.existsSync(direct) && fs.statSync(direct).isFile()) {
239
+ return direct;
240
+ }
241
+
242
+ const stack = [dir];
243
+ while (stack.length) {
244
+ const current = stack.pop();
245
+ let entries;
246
+ try {
247
+ entries = fs.readdirSync(current, { withFileTypes: true });
248
+ } catch {
249
+ continue;
250
+ }
251
+ for (const entry of entries) {
252
+ const full = path.join(current, entry.name);
253
+ if (entry.isDirectory()) {
254
+ stack.push(full);
255
+ } else if (entry.isFile() && (entry.name === binaryName || entry.name === 'astrolabe' || entry.name === 'astrolabe.exe')) {
256
+ return full;
257
+ }
258
+ }
259
+ }
260
+ throw new Error(`archive did not contain ${binaryName}`);
261
+ }
262
+
263
+ function ensureExecutable(file) {
264
+ if (process.platform === 'win32') return;
265
+ const mode = fs.statSync(file).mode;
266
+ if ((mode & 0o111) === 0) {
267
+ fs.chmodSync(file, mode | 0o755);
268
+ }
269
+ }
270
+
271
+ async function withLock(lockDir, fn) {
272
+ fs.mkdirSync(lockDir, { recursive: true });
273
+ const lockPath = path.join(lockDir, 'download.lock');
274
+ const started = Date.now();
275
+ for (;;) {
276
+ try {
277
+ const fd = fs.openSync(lockPath, 'wx');
278
+ try {
279
+ return await fn();
280
+ } finally {
281
+ fs.closeSync(fd);
282
+ try {
283
+ fs.unlinkSync(lockPath);
284
+ } catch {
285
+ // ignore
286
+ }
287
+ }
288
+ } catch (error) {
289
+ if (error.code !== 'EEXIST') throw error;
290
+ if (Date.now() - started > 120_000) {
291
+ throw new Error('timed out waiting for astrolabe binary download lock');
292
+ }
293
+ await sleep(200);
294
+ }
295
+ }
296
+ }
297
+
298
+ async function downloadRelease(platform, version) {
299
+ const destDir = path.join(cacheRoot(), version, platform.key);
300
+ const destBin = path.join(destDir, platform.binaryName);
301
+ if (fs.existsSync(destBin)) {
302
+ ensureExecutable(destBin);
303
+ return destBin;
304
+ }
305
+
306
+ return withLock(destDir, async () => {
307
+ if (fs.existsSync(destBin)) {
308
+ ensureExecutable(destBin);
309
+ return destBin;
310
+ }
311
+
312
+ const base = releasesBaseUrl(version);
313
+ const archive = archiveName(platform, version);
314
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astrolabe-dl-'));
315
+ const sumsPath = path.join(tmpDir, 'SHA256SUMS');
316
+ const archivePath = path.join(tmpDir, archive);
317
+ const extractDir = path.join(tmpDir, 'extract');
318
+
319
+ try {
320
+ await downloadToFile(`${base}/SHA256SUMS`, sumsPath);
321
+ const sums = parseSha256Sums(fs.readFileSync(sumsPath, 'utf8'));
322
+ const expected = sums.get(archive);
323
+ if (!expected) {
324
+ throw new Error(`SHA256SUMS has no entry for ${archive}`);
325
+ }
326
+
327
+ await downloadToFile(`${base}/${archive}`, archivePath);
328
+ const actual = await sha256File(archivePath);
329
+ if (actual !== expected) {
330
+ throw new Error(
331
+ `SHA256 mismatch for ${archive}: expected ${expected}, got ${actual}`,
332
+ );
333
+ }
334
+
335
+ extractArchive(archivePath, extractDir);
336
+ const extracted = findBinary(extractDir, platform.binaryName);
337
+ fs.copyFileSync(extracted, destBin);
338
+ ensureExecutable(destBin);
339
+ return destBin;
340
+ } finally {
341
+ fs.rmSync(tmpDir, { recursive: true, force: true });
342
+ }
343
+ });
344
+ }
345
+
346
+ module.exports = {
347
+ cacheRoot,
348
+ downloadRelease,
349
+ releasesBaseUrl,
350
+ releasesTagUrl,
351
+ releaseTokenFor,
352
+ ensureExecutable,
353
+ parseSha256Sums,
354
+ sha256File,
355
+ };
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+
6
+ /**
7
+ * Single source of truth for npm platform packages and GitHub Release artifacts.
8
+ *
9
+ * Release contract (consumed by the GitHub Releases pipeline):
10
+ * tag: v${version} e.g. v0.1.0
11
+ * unix: astrolabe-<version>-<rustTarget>.tar.gz
12
+ * windows: astrolabe-<version>-<rustTarget>.zip
13
+ * checksums: SHA256SUMS GNU coreutils format (`<hex> <filename>`)
14
+ * archive root contains the binary named `astrolabe` or `astrolabe.exe`.
15
+ */
16
+ const PLATFORMS = [
17
+ {
18
+ key: 'darwin-arm64',
19
+ npmPackage: '@zlnap/darwin-arm64',
20
+ rustTarget: 'aarch64-apple-darwin',
21
+ npmOs: 'darwin',
22
+ npmCpu: 'arm64',
23
+ npmLibc: null,
24
+ archiveExt: '.tar.gz',
25
+ binaryName: 'astrolabe',
26
+ },
27
+ {
28
+ key: 'darwin-x64',
29
+ npmPackage: '@zlnap/darwin-x64',
30
+ rustTarget: 'x86_64-apple-darwin',
31
+ npmOs: 'darwin',
32
+ npmCpu: 'x64',
33
+ npmLibc: null,
34
+ archiveExt: '.tar.gz',
35
+ binaryName: 'astrolabe',
36
+ },
37
+ {
38
+ key: 'linux-x64-gnu',
39
+ npmPackage: '@zlnap/linux-x64-gnu',
40
+ rustTarget: 'x86_64-unknown-linux-gnu',
41
+ npmOs: 'linux',
42
+ npmCpu: 'x64',
43
+ npmLibc: 'glibc',
44
+ archiveExt: '.tar.gz',
45
+ binaryName: 'astrolabe',
46
+ },
47
+ {
48
+ key: 'linux-x64-musl',
49
+ npmPackage: '@zlnap/linux-x64-musl',
50
+ rustTarget: 'x86_64-unknown-linux-musl',
51
+ npmOs: 'linux',
52
+ npmCpu: 'x64',
53
+ npmLibc: 'musl',
54
+ archiveExt: '.tar.gz',
55
+ binaryName: 'astrolabe',
56
+ },
57
+ {
58
+ key: 'linux-arm64-gnu',
59
+ npmPackage: '@zlnap/linux-arm64-gnu',
60
+ rustTarget: 'aarch64-unknown-linux-gnu',
61
+ npmOs: 'linux',
62
+ npmCpu: 'arm64',
63
+ npmLibc: 'glibc',
64
+ archiveExt: '.tar.gz',
65
+ binaryName: 'astrolabe',
66
+ },
67
+ {
68
+ key: 'win32-x64-msvc',
69
+ npmPackage: '@zlnap/win32-x64-msvc',
70
+ rustTarget: 'x86_64-pc-windows-msvc',
71
+ npmOs: 'win32',
72
+ npmCpu: 'x64',
73
+ npmLibc: null,
74
+ archiveExt: '.zip',
75
+ binaryName: 'astrolabe.exe',
76
+ },
77
+ ];
78
+
79
+ function archiveName(platform, version) {
80
+ return `astrolabe-${version}-${platform.rustTarget}${platform.archiveExt}`;
81
+ }
82
+
83
+ function isMuslLinux() {
84
+ if (process.platform !== 'linux') return false;
85
+
86
+ try {
87
+ const ldd = fs.readFileSync('/usr/bin/ldd', 'utf8');
88
+ if (ldd.includes('musl')) return true;
89
+ } catch {
90
+ // no /usr/bin/ldd
91
+ }
92
+
93
+ try {
94
+ if (fs.existsSync('/etc/alpine-release')) return true;
95
+ } catch {
96
+ // ignore
97
+ }
98
+
99
+ try {
100
+ if (typeof process.report?.getReport === 'function') {
101
+ const report = process.report.getReport();
102
+ if (report && typeof report === 'object') {
103
+ const shared = report.sharedObjects || [];
104
+ if (shared.some((entry) => /libc\.musl-/.test(entry))) return true;
105
+ const glibc = report.header && report.header.glibcVersionRuntime;
106
+ if (glibc) return false;
107
+ }
108
+ }
109
+ } catch {
110
+ // ignore
111
+ }
112
+
113
+ return false;
114
+ }
115
+
116
+ function detectPlatform() {
117
+ const npmOs = process.platform;
118
+ const npmCpu = os.arch();
119
+ const npmLibc = npmOs === 'linux' ? (isMuslLinux() ? 'musl' : 'glibc') : null;
120
+
121
+ const match = PLATFORMS.find((platform) => {
122
+ if (platform.npmOs !== npmOs) return false;
123
+ if (platform.npmCpu !== npmCpu) return false;
124
+ if (platform.npmLibc && platform.npmLibc !== npmLibc) return false;
125
+ return true;
126
+ });
127
+
128
+ if (!match) {
129
+ const host = `${npmOs}-${npmCpu}${npmLibc ? `-${npmLibc}` : ''}`;
130
+ const supported = PLATFORMS.map((platform) => platform.key).join(', ');
131
+ throw new Error(
132
+ `astrolabe has no prebuilt binary for ${host}. Supported platforms: ${supported}`,
133
+ );
134
+ }
135
+
136
+ return match;
137
+ }
138
+
139
+ function platformByKey(key) {
140
+ const match = PLATFORMS.find((platform) => platform.key === key);
141
+ if (!match) {
142
+ throw new Error(`unknown platform key: ${key}`);
143
+ }
144
+ return match;
145
+ }
146
+
147
+ function platformByRustTarget(rustTarget) {
148
+ return PLATFORMS.find((platform) => platform.rustTarget === rustTarget) || null;
149
+ }
150
+
151
+ module.exports = {
152
+ PLATFORMS,
153
+ archiveName,
154
+ detectPlatform,
155
+ isMuslLinux,
156
+ platformByKey,
157
+ platformByRustTarget,
158
+ };
package/lib/resolve.js ADDED
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { downloadRelease, ensureExecutable, releasesTagUrl } = require('./download');
7
+ const { PLATFORMS, detectPlatform } = require('./platforms');
8
+
9
+ const VERSION = require('../package.json').version;
10
+
11
+ function packageRoot(npmPackage) {
12
+ const pkgJson = require.resolve(`${npmPackage}/package.json`);
13
+ return path.dirname(pkgJson);
14
+ }
15
+
16
+ function listInstalledPlatformPackages() {
17
+ const found = [];
18
+ for (const platform of PLATFORMS) {
19
+ try {
20
+ found.push(platform.npmPackage);
21
+ require.resolve(`${platform.npmPackage}/package.json`);
22
+ } catch {
23
+ found.pop();
24
+ }
25
+ }
26
+ return found;
27
+ }
28
+
29
+ function materializeIfVirtual(binPath, platform) {
30
+ const normalized = binPath.replace(/\\/g, '/');
31
+ if (!normalized.includes('.zip/')) {
32
+ return binPath;
33
+ }
34
+ const destDir = path.join(
35
+ require('./download').cacheRoot(),
36
+ VERSION,
37
+ `${platform.key}-pnp`,
38
+ );
39
+ const dest = path.join(destDir, platform.binaryName);
40
+ if (!fs.existsSync(dest)) {
41
+ fs.mkdirSync(destDir, { recursive: true });
42
+ fs.copyFileSync(binPath, dest);
43
+ ensureExecutable(dest);
44
+ }
45
+ return dest;
46
+ }
47
+
48
+ function resolveOptionalBinary(platform) {
49
+ const binPath = path.join(packageRoot(platform.npmPackage), platform.binaryName);
50
+ if (!fs.existsSync(binPath)) {
51
+ throw Object.assign(
52
+ new Error(
53
+ `${platform.npmPackage} is installed but ${platform.binaryName} is missing`,
54
+ ),
55
+ { code: 'ASTROLABE_BINARY_MISSING' },
56
+ );
57
+ }
58
+ return materializeIfVirtual(binPath, platform);
59
+ }
60
+
61
+ function envOverridePath() {
62
+ const fromEnv = process.env.ASTROLABE_BINARY_PATH;
63
+ if (!fromEnv) return null;
64
+ if (!fs.existsSync(fromEnv)) {
65
+ console.error(`[astrolabe] ignoring ASTROLABE_BINARY_PATH, file not found: ${fromEnv}`);
66
+ return null;
67
+ }
68
+ return fromEnv;
69
+ }
70
+
71
+ async function resolveBinary() {
72
+ const override = envOverridePath();
73
+ if (override) return override;
74
+
75
+ const platform = detectPlatform();
76
+
77
+ try {
78
+ const binPath = resolveOptionalBinary(platform);
79
+ ensureExecutable(binPath);
80
+ return binPath;
81
+ } catch (error) {
82
+ if (error && error.code !== 'MODULE_NOT_FOUND' && error.code !== 'ASTROLABE_BINARY_MISSING') {
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ try {
88
+ return await downloadRelease(platform, VERSION);
89
+ } catch (downloadError) {
90
+ const others = listInstalledPlatformPackages().filter(
91
+ (name) => name !== platform.npmPackage,
92
+ );
93
+ const otherHint = others.length
94
+ ? `\nFound platform package(s) for a different architecture: ${others.join(', ')}.\n` +
95
+ `This usually means node_modules was copied across OS/arch, or the lockfile was generated by npm < 11 which can drop optionalDependencies.`
96
+ : `\nCommon causes: npm/pnpm/yarn --omit=optional, --ignore-scripts, or a lockfile generated on another OS/arch (npm < 11 bug).`;
97
+
98
+ throw new Error(
99
+ `Could not locate ${platform.npmPackage} (needed on this machine) and GitHub Releases fallback failed: ${downloadError.message}` +
100
+ otherHint +
101
+ `\nTried: ${require('./download').cacheRoot()}/${VERSION}/${platform.key}/` +
102
+ `\nReleases: ${releasesTagUrl(VERSION)}`,
103
+ );
104
+ }
105
+ }
106
+
107
+ module.exports = { resolveBinary };