@starkscan/cli 0.1.0-alpha.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 +56 -0
- package/artifacts/v0.1.0-alpha.0/starkscan-cli-manifest.json +34 -0
- package/artifacts/v0.1.0-alpha.0/starkscan-darwin-aarch64.tar.gz +0 -0
- package/artifacts/v0.1.0-alpha.0/starkscan-darwin-aarch64.tar.gz.sha256 +1 -0
- package/artifacts/v0.1.0-alpha.0/starkscan-darwin-x86_64.tar.gz +0 -0
- package/artifacts/v0.1.0-alpha.0/starkscan-darwin-x86_64.tar.gz.sha256 +1 -0
- package/artifacts/v0.1.0-alpha.0/starkscan-linux-aarch64.tar.gz +0 -0
- package/artifacts/v0.1.0-alpha.0/starkscan-linux-aarch64.tar.gz.sha256 +1 -0
- package/artifacts/v0.1.0-alpha.0/starkscan-linux-x86_64.tar.gz +0 -0
- package/artifacts/v0.1.0-alpha.0/starkscan-linux-x86_64.tar.gz.sha256 +1 -0
- package/bin/starkscan.js +35 -0
- package/package.json +48 -0
- package/scripts/install.mjs +33 -0
- package/scripts/installer.mjs +471 -0
- package/scripts/verify-bundled-artifacts.mjs +134 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @starkscan/cli
|
|
2
|
+
|
|
3
|
+
npm/npx launcher for the prebuilt Starkscan CLI.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @starkscan/cli@alpha
|
|
9
|
+
starkscan doctor
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
One-off agent run:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx @starkscan/cli@alpha doctor
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The wrapper uses bundled native artifacts when they are present in the published package, verifies downloaded or bundled archives against checksums in the release manifest, rejects malformed archives, caches the native `starkscan` binary, and forwards all arguments to it. It does not require a Rust toolchain or repository access.
|
|
19
|
+
|
|
20
|
+
MCP client setup uses the same package. `print-config` emits machine-readable
|
|
21
|
+
Codex and Claude Code snippets with environment-variable placeholders, not
|
|
22
|
+
secret values. `start` is the stable alias for serving the remote stdio bridge:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx -y @starkscan/cli@alpha mcp print-config --transport remote
|
|
26
|
+
npx -y @starkscan/cli@alpha mcp start --transport remote
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Environment
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Use the URL prefix that directly serves `/v1/...`.
|
|
33
|
+
# Some public hosts may expose the API under `/api`; do not include `/v1`.
|
|
34
|
+
export STARKSCAN_BASE_URL="https://REPLACE_WITH_STARKSCAN_API_HOST"
|
|
35
|
+
export STARKSCAN_API_KEY="mzk_test_your_key_here"
|
|
36
|
+
export STARKSCAN_CHAIN="SN_MAIN"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`STARKSCAN_*` is the public CLI environment contract. The launcher still accepts
|
|
40
|
+
the old internal variable names as hidden compatibility aliases during the beta
|
|
41
|
+
cutover. The CLI sends `X-Starkscan-Api-Key` to the hosted API.
|
|
42
|
+
|
|
43
|
+
## Release binding
|
|
44
|
+
|
|
45
|
+
By default, the package resolves to bundled artifacts under `artifacts/v<package-version>` when published with native binaries included. Maintainers can override the release source for tests or emergency rollback:
|
|
46
|
+
|
|
47
|
+
- `STARKSCAN_CLI_RELEASE_TAG`: release tag, or `latest`
|
|
48
|
+
- `STARKSCAN_CLI_BUNDLED_ARTIFACT_DIR`: maintainer/test override for the bundled artifact root. Use an absolute path in CI, or a cwd-relative path locally, pointing to a root that contains `artifacts/v<release-tag>/starkscan-cli-manifest.json` and matching platform archives. Example: `STARKSCAN_CLI_BUNDLED_ARTIFACT_DIR=/tmp/starkscan-cli-artifacts npx @starkscan/cli@alpha doctor`
|
|
49
|
+
- `STARKSCAN_CLI_RELEASE_BASE_URL`: release base URL, default `https://github.com/starknet-innovation/starkscan/releases`
|
|
50
|
+
- `STARKSCAN_CLI_CACHE_DIR`: cache root for downloaded native binaries
|
|
51
|
+
- `STARKSCAN_CLI_BIN_PATH`: use an existing local `starkscan` binary and skip download
|
|
52
|
+
- `STARKSCAN_CLI_RELEASE_REPO`: GitHub repo path, default `starknet-innovation/starkscan`
|
|
53
|
+
- `STARKSCAN_CLI_PLATFORM_OVERRIDE`: force a specific target platform for cross-platform testing
|
|
54
|
+
- `STARKSCAN_CLI_DOWNLOAD_TIMEOUT_MS`: download inactivity timeout in milliseconds, default `120000`
|
|
55
|
+
|
|
56
|
+
The package expects release assets produced by `rust-exp/scripts/package-starkscan-cli.sh`: `starkscan-cli-manifest.json`, `starkscan-<platform>.tar.gz`, and matching checksums. Run native packaging and npm packaging from the repository root so `.artifacts/release/cli` is available. Publish the tarball produced by `scripts/package-starkscan-cli-npm.sh` or `scripts/publish-public-cli.sh`; do not publish the package directory directly, because that can omit native artifacts.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"generatedAt": "2026-05-28T07:50:25Z",
|
|
4
|
+
"artifacts": [
|
|
5
|
+
{
|
|
6
|
+
"name": "starkscan-darwin-aarch64.tar.gz",
|
|
7
|
+
"platform": "darwin-aarch64",
|
|
8
|
+
"target": "darwin-aarch64",
|
|
9
|
+
"archive": "starkscan-darwin-aarch64.tar.gz",
|
|
10
|
+
"sha256": "ab12b551feb00ea3680b098dae25345f96fb22a1715c7f576488fd02ee6120e7"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "starkscan-darwin-x86_64.tar.gz",
|
|
14
|
+
"platform": "darwin-x86_64",
|
|
15
|
+
"target": "darwin-x86_64",
|
|
16
|
+
"archive": "starkscan-darwin-x86_64.tar.gz",
|
|
17
|
+
"sha256": "d2eb41a1a8c70cfe2ba064fcc018b4c2c34ea7b1d2fc2f61a004ed365d8a43d6"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "starkscan-linux-aarch64.tar.gz",
|
|
21
|
+
"platform": "linux-aarch64",
|
|
22
|
+
"target": "linux-aarch64",
|
|
23
|
+
"archive": "starkscan-linux-aarch64.tar.gz",
|
|
24
|
+
"sha256": "18e93c6e3ccbce6d9084607f8f86c729b8cb6a67a65d05e566be8f8ad066e250"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "starkscan-linux-x86_64.tar.gz",
|
|
28
|
+
"platform": "linux-x86_64",
|
|
29
|
+
"target": "linux-x86_64",
|
|
30
|
+
"archive": "starkscan-linux-x86_64.tar.gz",
|
|
31
|
+
"sha256": "1a97868b0096dc491c0b6b2f7b41e5056d9581c4117010f25ae769ee9e14bd03"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ab12b551feb00ea3680b098dae25345f96fb22a1715c7f576488fd02ee6120e7 starkscan-darwin-aarch64.tar.gz
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
d2eb41a1a8c70cfe2ba064fcc018b4c2c34ea7b1d2fc2f61a004ed365d8a43d6 starkscan-darwin-x86_64.tar.gz
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
18e93c6e3ccbce6d9084607f8f86c729b8cb6a67a65d05e566be8f8ad066e250 starkscan-linux-aarch64.tar.gz
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1a97868b0096dc491c0b6b2f7b41e5056d9581c4117010f25ae769ee9e14bd03 starkscan-linux-x86_64.tar.gz
|
package/bin/starkscan.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { constants as osConstants } from 'node:os';
|
|
4
|
+
|
|
5
|
+
import { ensureStarkscanBinary } from '../scripts/installer.mjs';
|
|
6
|
+
|
|
7
|
+
function exitFromResult(result) {
|
|
8
|
+
if (result.error) {
|
|
9
|
+
console.error(`starkscan: failed to launch binary: ${result.error.message}`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
if (result.signal) {
|
|
13
|
+
console.error(`starkscan: binary terminated by signal ${result.signal}`);
|
|
14
|
+
const signalCode = osConstants.signals[result.signal];
|
|
15
|
+
try {
|
|
16
|
+
process.kill(process.pid, result.signal);
|
|
17
|
+
} catch {
|
|
18
|
+
process.exit(signalCode ? 128 + signalCode : 1);
|
|
19
|
+
}
|
|
20
|
+
setTimeout(() => process.exit(signalCode ? 128 + signalCode : 1), 1000);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
process.exit(result.status ?? 1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const binaryPath = await ensureStarkscanBinary();
|
|
28
|
+
const result = spawnSync(binaryPath, process.argv.slice(2), {
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
});
|
|
31
|
+
exitFromResult(result);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(`starkscan: ${error instanceof Error ? error.message : String(error)}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@starkscan/cli",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.1.0-alpha.0",
|
|
5
|
+
"description": "npm/npx launcher for the prebuilt Starkscan explorer CLI.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public",
|
|
10
|
+
"provenance": true
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/starknet-innovation/mezcal.git",
|
|
15
|
+
"directory": "webapp/packages/cli"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/starknet-innovation/mezcal/tree/main/webapp/packages/cli",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"starkscan",
|
|
20
|
+
"starknet",
|
|
21
|
+
"explorer",
|
|
22
|
+
"cli",
|
|
23
|
+
"agent"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"artifacts",
|
|
27
|
+
"bin",
|
|
28
|
+
"scripts/install.mjs",
|
|
29
|
+
"scripts/installer.mjs",
|
|
30
|
+
"scripts/verify-bundled-artifacts.mjs",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"bin": {
|
|
34
|
+
"starkscan": "./bin/starkscan.js"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"install:binary": "node ./scripts/install.mjs",
|
|
38
|
+
"prepack": "npm run verify:bundled && npm run typecheck && npm test",
|
|
39
|
+
"prepublishOnly": "npm run verify:bundled && npm run typecheck && npm test",
|
|
40
|
+
"publish:alpha:dry-run": "./scripts/publish-public-cli.sh --tag alpha --dry-run",
|
|
41
|
+
"test": "node ./scripts/tests/cli-wrapper.mjs && ./scripts/tests/publish-public-cli.sh",
|
|
42
|
+
"typecheck": "node --check ./bin/starkscan.js && node --check ./scripts/install.mjs && node --check ./scripts/installer.mjs && node --check ./scripts/verify-bundled-artifacts.mjs && node --check ./scripts/tests/cli-wrapper.mjs",
|
|
43
|
+
"verify:bundled": "node ./scripts/verify-bundled-artifacts.mjs"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ensureStarkscanBinary } from './installer.mjs';
|
|
3
|
+
|
|
4
|
+
function usage() {
|
|
5
|
+
console.log(`Usage: node scripts/install.mjs [--force] [--print-path]\n\nDownloads and verifies the prebuilt Starkscan CLI release artifact for this platform.`);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let force = false;
|
|
9
|
+
let printPath = false;
|
|
10
|
+
for (const arg of process.argv.slice(2)) {
|
|
11
|
+
if (arg === '--force') {
|
|
12
|
+
force = true;
|
|
13
|
+
} else if (arg === '--print-path') {
|
|
14
|
+
printPath = true;
|
|
15
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
16
|
+
usage();
|
|
17
|
+
process.exit(0);
|
|
18
|
+
} else {
|
|
19
|
+
console.error(`unknown argument: ${arg}`);
|
|
20
|
+
usage();
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const binaryPath = await ensureStarkscanBinary({ force });
|
|
27
|
+
if (printPath) {
|
|
28
|
+
console.log(binaryPath);
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(`install-starkscan-cli: ${error instanceof Error ? error.message : String(error)}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
4
|
+
import { copyFile, chmod, lstat, mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises';
|
|
5
|
+
import { get as httpGet } from 'node:http';
|
|
6
|
+
import { get as httpsGet } from 'node:https';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const PACKAGE_JSON_PATH = path.join(PACKAGE_ROOT, 'package.json');
|
|
13
|
+
const DEFAULT_BUNDLED_ARTIFACT_ROOT = path.join(PACKAGE_ROOT, 'artifacts');
|
|
14
|
+
const DEFAULT_REPO = 'starknet-innovation/starkscan';
|
|
15
|
+
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
16
|
+
const MAX_REDIRECTS = 10;
|
|
17
|
+
const RELEASE_TAG_RE = /^(?=.*[A-Za-z0-9])(?!-)(?!.*\.\.)[A-Za-z0-9._-]+$/;
|
|
18
|
+
const SUPPORTED_PLATFORMS = new Set([
|
|
19
|
+
'darwin-aarch64',
|
|
20
|
+
'darwin-x86_64',
|
|
21
|
+
'linux-aarch64',
|
|
22
|
+
'linux-x86_64',
|
|
23
|
+
]);
|
|
24
|
+
const PUBLIC_BINARY_NAME = 'starkscan';
|
|
25
|
+
const LEGACY_BINARY_NAME = 'mezcal';
|
|
26
|
+
const PUBLIC_MANIFEST_FILENAME = 'starkscan-cli-manifest.json';
|
|
27
|
+
const LEGACY_MANIFEST_FILENAME = 'mezcal-cli-manifest.json';
|
|
28
|
+
|
|
29
|
+
function env(name) {
|
|
30
|
+
const value = process.env[name]?.trim();
|
|
31
|
+
return value && value.length > 0 ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function envFirst(primaryName, legacyName) {
|
|
35
|
+
return env(primaryName) ?? env(legacyName);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cliEnv(suffix) {
|
|
39
|
+
return envFirst(`STARKSCAN_CLI_${suffix}`, `MEZCAL_CLI_${suffix}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fail(message) {
|
|
43
|
+
throw new Error(message);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function packageVersion() {
|
|
47
|
+
const raw = await readFile(PACKAGE_JSON_PATH, 'utf8');
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
if (typeof parsed.version !== 'string' || parsed.version.length === 0) {
|
|
50
|
+
fail('package.json version is missing');
|
|
51
|
+
}
|
|
52
|
+
return parsed.version;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolvePlatform() {
|
|
56
|
+
const override = cliEnv('PLATFORM_OVERRIDE');
|
|
57
|
+
if (override) {
|
|
58
|
+
if (!SUPPORTED_PLATFORMS.has(override)) {
|
|
59
|
+
fail(`unsupported STARKSCAN_CLI_PLATFORM_OVERRIDE: ${override}`);
|
|
60
|
+
}
|
|
61
|
+
return override;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const osName = os.platform();
|
|
65
|
+
const archName = os.arch();
|
|
66
|
+
let osPart;
|
|
67
|
+
let archPart;
|
|
68
|
+
|
|
69
|
+
if (osName === 'darwin') {
|
|
70
|
+
osPart = 'darwin';
|
|
71
|
+
} else if (osName === 'linux') {
|
|
72
|
+
osPart = 'linux';
|
|
73
|
+
} else {
|
|
74
|
+
fail(`unsupported operating system: ${osName}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (archName === 'x64') {
|
|
78
|
+
archPart = 'x86_64';
|
|
79
|
+
} else if (archName === 'arm64') {
|
|
80
|
+
archPart = 'aarch64';
|
|
81
|
+
} else {
|
|
82
|
+
fail(`unsupported architecture: ${archName}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return `${osPart}-${archPart}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function defaultCacheRoot() {
|
|
89
|
+
const configured = cliEnv('CACHE_DIR');
|
|
90
|
+
if (configured) {
|
|
91
|
+
return configured;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (process.platform === 'darwin') {
|
|
95
|
+
const home = env('HOME');
|
|
96
|
+
if (!home) {
|
|
97
|
+
fail('HOME is required when STARKSCAN_CLI_CACHE_DIR is not set');
|
|
98
|
+
}
|
|
99
|
+
return path.join(home, 'Library', 'Caches', 'starkscan', 'cli');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const xdgCache = env('XDG_CACHE_HOME');
|
|
103
|
+
if (xdgCache) {
|
|
104
|
+
return path.join(xdgCache, 'starkscan', 'cli');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const home = env('HOME');
|
|
108
|
+
if (!home) {
|
|
109
|
+
fail('HOME is required when STARKSCAN_CLI_CACHE_DIR is not set');
|
|
110
|
+
}
|
|
111
|
+
return path.join(home, '.cache', 'starkscan', 'cli');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function validateExecutableFile(binaryPath, label) {
|
|
115
|
+
const stat = await lstat(binaryPath).catch(() => null);
|
|
116
|
+
if (!stat) {
|
|
117
|
+
fail(`${label} not found: ${binaryPath}`);
|
|
118
|
+
}
|
|
119
|
+
if (stat.isSymbolicLink()) {
|
|
120
|
+
fail(`${label} must not be a symlink: ${binaryPath}`);
|
|
121
|
+
}
|
|
122
|
+
if (!stat.isFile()) {
|
|
123
|
+
fail(`${label} must be a file: ${binaryPath}`);
|
|
124
|
+
}
|
|
125
|
+
if ((stat.mode & 0o111) === 0) {
|
|
126
|
+
fail(`${label} is not executable: ${binaryPath}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runChecked(command, args, options = {}) {
|
|
131
|
+
const result = spawnSync(command, args, {
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
...options,
|
|
134
|
+
});
|
|
135
|
+
if (result.error) {
|
|
136
|
+
fail(`${command} failed: ${result.error.message}`);
|
|
137
|
+
}
|
|
138
|
+
if (result.status !== 0) {
|
|
139
|
+
const stderr = result.stderr?.trim();
|
|
140
|
+
fail(stderr ? `${command} failed: ${stderr}` : `${command} failed with exit ${result.status}`);
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function sha256File(filePath) {
|
|
146
|
+
return await new Promise((resolve, reject) => {
|
|
147
|
+
const hash = createHash('sha256');
|
|
148
|
+
const input = createReadStream(filePath);
|
|
149
|
+
input.on('error', reject);
|
|
150
|
+
input.on('data', (chunk) => hash.update(chunk));
|
|
151
|
+
input.on('end', () => resolve(hash.digest('hex')));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function resolveDownloadTimeoutMs() {
|
|
156
|
+
const configured = cliEnv('DOWNLOAD_TIMEOUT_MS');
|
|
157
|
+
if (!configured) {
|
|
158
|
+
return DEFAULT_DOWNLOAD_TIMEOUT_MS;
|
|
159
|
+
}
|
|
160
|
+
if (!/^[0-9]+$/.test(configured)) {
|
|
161
|
+
fail(`STARKSCAN_CLI_DOWNLOAD_TIMEOUT_MS must be a positive integer: ${configured}`);
|
|
162
|
+
}
|
|
163
|
+
const timeoutMs = Number(configured);
|
|
164
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000) {
|
|
165
|
+
fail('STARKSCAN_CLI_DOWNLOAD_TIMEOUT_MS must be at least 1000');
|
|
166
|
+
}
|
|
167
|
+
return timeoutMs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function releaseTagFromVersion(version) {
|
|
171
|
+
return version.startsWith('v') ? version : `v${version}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function resolveReleaseTag() {
|
|
175
|
+
const tag = cliEnv('RELEASE_TAG') ?? env('MEZCAL_INSTALL_VERSION') ?? releaseTagFromVersion(await packageVersion());
|
|
176
|
+
if (tag !== 'latest' && !RELEASE_TAG_RE.test(tag)) {
|
|
177
|
+
fail(`unsupported release tag: ${tag}`);
|
|
178
|
+
}
|
|
179
|
+
return tag;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function releaseRootUrl(baseUrl, releaseTag) {
|
|
183
|
+
const normalizedBase = baseUrl.replace(/\/+$/, '');
|
|
184
|
+
if (releaseTag === 'latest') {
|
|
185
|
+
return `${normalizedBase}/latest/download`;
|
|
186
|
+
}
|
|
187
|
+
return `${normalizedBase}/download/${releaseTag}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function releaseAssetUrl(rootUrl, filename) {
|
|
191
|
+
return `${rootUrl}/${filename}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function bundledArtifactRoot() {
|
|
195
|
+
const configured = cliEnv('BUNDLED_ARTIFACT_DIR');
|
|
196
|
+
if (!configured) {
|
|
197
|
+
return DEFAULT_BUNDLED_ARTIFACT_ROOT;
|
|
198
|
+
}
|
|
199
|
+
return path.resolve(configured);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function bundledArtifactRootUrl(releaseTag) {
|
|
203
|
+
return pathToFileURL(path.join(bundledArtifactRoot(), releaseTag)).href;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function hasBundledArtifacts(releaseTag) {
|
|
207
|
+
for (const manifestFilename of [PUBLIC_MANIFEST_FILENAME, LEGACY_MANIFEST_FILENAME]) {
|
|
208
|
+
const manifestPath = path.join(bundledArtifactRoot(), releaseTag, manifestFilename);
|
|
209
|
+
const stat = await lstat(manifestPath).catch(() => null);
|
|
210
|
+
if (stat?.isFile()) {
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function resolveRedirectUrl(url, location, redirectsLeft) {
|
|
218
|
+
if (redirectsLeft <= 0) {
|
|
219
|
+
fail(`too many redirects downloading ${url}`);
|
|
220
|
+
}
|
|
221
|
+
const currentUrl = new URL(url);
|
|
222
|
+
const redirectUrl = new URL(location, url);
|
|
223
|
+
if (redirectUrl.protocol !== 'https:' && redirectUrl.protocol !== 'http:') {
|
|
224
|
+
fail(`unsupported redirect URL protocol: ${redirectUrl.protocol}`);
|
|
225
|
+
}
|
|
226
|
+
if (currentUrl.protocol === 'https:' && redirectUrl.protocol === 'http:') {
|
|
227
|
+
fail(`redirect from HTTPS to HTTP is not allowed: ${redirectUrl.toString()}`);
|
|
228
|
+
}
|
|
229
|
+
return redirectUrl.toString();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function downloadHttp(url, destination, redirectsLeft = MAX_REDIRECTS) {
|
|
233
|
+
await new Promise((resolve, reject) => {
|
|
234
|
+
const currentUrl = new URL(url);
|
|
235
|
+
const getter = currentUrl.protocol === 'https:' ? httpsGet : httpGet;
|
|
236
|
+
const timeoutMs = resolveDownloadTimeoutMs();
|
|
237
|
+
const request = getter(url, (response) => {
|
|
238
|
+
if (
|
|
239
|
+
response.statusCode &&
|
|
240
|
+
response.statusCode >= 300 &&
|
|
241
|
+
response.statusCode < 400 &&
|
|
242
|
+
response.headers.location
|
|
243
|
+
) {
|
|
244
|
+
let redirectUrl;
|
|
245
|
+
try {
|
|
246
|
+
redirectUrl = resolveRedirectUrl(url, response.headers.location, redirectsLeft);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
response.resume();
|
|
249
|
+
reject(error);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
response.resume();
|
|
253
|
+
downloadHttp(redirectUrl, destination, redirectsLeft - 1).then(resolve, reject);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (response.statusCode !== 200) {
|
|
257
|
+
response.resume();
|
|
258
|
+
reject(new Error(`download failed for ${url}: HTTP ${response.statusCode}`));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const output = createWriteStream(destination, { flags: 'wx' });
|
|
263
|
+
output.on('error', reject);
|
|
264
|
+
output.on('finish', resolve);
|
|
265
|
+
response.pipe(output);
|
|
266
|
+
});
|
|
267
|
+
request.setTimeout(timeoutMs, () => {
|
|
268
|
+
request.destroy(new Error(`download timed out after ${timeoutMs}ms: ${url}`));
|
|
269
|
+
});
|
|
270
|
+
request.on('error', reject);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function downloadFile(url, destination) {
|
|
275
|
+
const parsed = new URL(url);
|
|
276
|
+
if (parsed.protocol === 'file:') {
|
|
277
|
+
await copyFile(fileURLToPath(parsed), destination);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
281
|
+
fail(`unsupported download URL protocol: ${parsed.protocol}`);
|
|
282
|
+
}
|
|
283
|
+
await downloadHttp(url, destination);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function errorMessage(error) {
|
|
287
|
+
return error instanceof Error ? error.message : String(error);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isMissingManifestDownload(error) {
|
|
291
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
if (!(error instanceof Error)) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
return error.message.includes('HTTP 404') || error.message.includes('ENOENT');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function readDownloadedManifest(manifestPath, manifestFilename) {
|
|
301
|
+
try {
|
|
302
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
303
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
304
|
+
fail('release manifest root must be an object');
|
|
305
|
+
}
|
|
306
|
+
if (manifest.schemaVersion !== 1) {
|
|
307
|
+
fail('release manifest schemaVersion must be 1');
|
|
308
|
+
}
|
|
309
|
+
if (!Array.isArray(manifest.artifacts)) {
|
|
310
|
+
fail('release manifest artifacts must be an array');
|
|
311
|
+
}
|
|
312
|
+
return manifest;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
fail(`release manifest ${manifestFilename} is invalid: ${errorMessage(error)}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function validateArtifact(artifact, platform) {
|
|
319
|
+
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
|
|
320
|
+
fail('release manifest artifact entries must be objects');
|
|
321
|
+
}
|
|
322
|
+
if (artifact.platform !== platform) {
|
|
323
|
+
fail(`release manifest selected wrong platform: ${artifact.platform}`);
|
|
324
|
+
}
|
|
325
|
+
const acceptedArchives = [`starkscan-${platform}.tar.gz`, `mezcal-${platform}.tar.gz`];
|
|
326
|
+
if (artifact.archive === undefined && artifact.name === undefined) {
|
|
327
|
+
fail(`release manifest artifact for ${platform} must use one of ${acceptedArchives.join(', ')}`);
|
|
328
|
+
}
|
|
329
|
+
if (artifact.archive !== undefined && !acceptedArchives.includes(artifact.archive)) {
|
|
330
|
+
fail(`release manifest artifact archive for ${platform} must be one of ${acceptedArchives.join(', ')}`);
|
|
331
|
+
}
|
|
332
|
+
if (artifact.name !== undefined && !acceptedArchives.includes(artifact.name)) {
|
|
333
|
+
fail(`release manifest artifact name for ${platform} must be one of ${acceptedArchives.join(', ')}`);
|
|
334
|
+
}
|
|
335
|
+
if (artifact.archive !== undefined && artifact.name !== undefined && artifact.archive !== artifact.name) {
|
|
336
|
+
fail(`release manifest artifact name and archive for ${platform} must match`);
|
|
337
|
+
}
|
|
338
|
+
if (typeof artifact.sha256 !== 'string' || !/^[0-9a-fA-F]{64}$/.test(artifact.sha256)) {
|
|
339
|
+
fail(`release manifest artifact for ${platform} has invalid sha256`);
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
archive: artifact.archive ?? artifact.name,
|
|
343
|
+
sha256: artifact.sha256.toLowerCase(),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function readManifest(rootUrl, tempDir) {
|
|
348
|
+
const attempts = [];
|
|
349
|
+
for (const manifestFilename of [PUBLIC_MANIFEST_FILENAME, LEGACY_MANIFEST_FILENAME]) {
|
|
350
|
+
const manifestPath = path.join(tempDir, manifestFilename);
|
|
351
|
+
try {
|
|
352
|
+
await downloadFile(releaseAssetUrl(rootUrl, manifestFilename), manifestPath);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
await rm(manifestPath, { force: true }).catch(() => {});
|
|
355
|
+
if (!isMissingManifestDownload(error)) {
|
|
356
|
+
fail(`release manifest ${manifestFilename} could not be downloaded: ${errorMessage(error)}`);
|
|
357
|
+
}
|
|
358
|
+
attempts.push(`${manifestFilename}: ${errorMessage(error)}`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
return await readDownloadedManifest(manifestPath, manifestFilename);
|
|
362
|
+
}
|
|
363
|
+
fail(`release manifest not found; tried ${attempts.join('; ')}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function installFromRelease({ platform, releaseTag, cachePath, rootUrl }) {
|
|
367
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'starkscan-npm-cli-'));
|
|
368
|
+
try {
|
|
369
|
+
const manifest = await readManifest(rootUrl, tempDir);
|
|
370
|
+
const matching = manifest.artifacts.filter((artifact) => artifact?.platform === platform);
|
|
371
|
+
if (matching.length !== 1) {
|
|
372
|
+
fail(`release manifest must contain exactly one artifact for ${platform}; found ${matching.length}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const artifact = validateArtifact(matching[0], platform);
|
|
376
|
+
const archivePath = path.join(tempDir, artifact.archive);
|
|
377
|
+
await downloadFile(releaseAssetUrl(rootUrl, artifact.archive), archivePath);
|
|
378
|
+
|
|
379
|
+
const actualSha = await sha256File(archivePath);
|
|
380
|
+
if (actualSha !== artifact.sha256) {
|
|
381
|
+
fail(`checksum mismatch for ${artifact.archive}: expected ${artifact.sha256}, actual ${actualSha}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const listing = runChecked('tar', ['-tzf', archivePath]).stdout.trim();
|
|
385
|
+
if (listing !== PUBLIC_BINARY_NAME && listing !== LEGACY_BINARY_NAME) {
|
|
386
|
+
fail(`release artifact ${artifact.archive} must contain exactly one top-level Starkscan binary`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const extractDir = path.join(tempDir, 'extract');
|
|
390
|
+
await mkdir(extractDir, { recursive: true });
|
|
391
|
+
runChecked('tar', ['-C', extractDir, '-xzf', archivePath]);
|
|
392
|
+
const extractedBinary = path.join(extractDir, listing);
|
|
393
|
+
await validateExecutableFile(extractedBinary, 'release artifact Starkscan binary');
|
|
394
|
+
|
|
395
|
+
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
396
|
+
const stagePath = `${cachePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
397
|
+
try {
|
|
398
|
+
await copyFile(extractedBinary, stagePath);
|
|
399
|
+
await chmod(stagePath, 0o755);
|
|
400
|
+
await rename(stagePath, cachePath);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
await rm(stagePath, { force: true }).catch(() => {});
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
await validateExecutableFile(cachePath, 'cached Starkscan binary');
|
|
406
|
+
return cachePath;
|
|
407
|
+
} finally {
|
|
408
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export async function ensureStarkscanBinary(options = {}) {
|
|
413
|
+
const explicitBinary = cliEnv('BIN_PATH');
|
|
414
|
+
if (explicitBinary) {
|
|
415
|
+
await validateExecutableFile(explicitBinary, 'STARKSCAN_CLI_BIN_PATH');
|
|
416
|
+
return explicitBinary;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const platform = resolvePlatform();
|
|
420
|
+
const releaseTag = await resolveReleaseTag();
|
|
421
|
+
const cacheDir = path.join(defaultCacheRoot(), releaseTag, platform);
|
|
422
|
+
const cachePath = path.join(cacheDir, PUBLIC_BINARY_NAME);
|
|
423
|
+
const allowCacheReuse = !options.force && releaseTag !== 'latest';
|
|
424
|
+
if (allowCacheReuse) {
|
|
425
|
+
const existing = await lstat(cachePath).catch(() => null);
|
|
426
|
+
if (existing) {
|
|
427
|
+
await validateExecutableFile(cachePath, 'cached Starkscan binary');
|
|
428
|
+
return cachePath;
|
|
429
|
+
}
|
|
430
|
+
const legacyCachePath = path.join(cacheDir, LEGACY_BINARY_NAME);
|
|
431
|
+
const legacyExisting = await lstat(legacyCachePath).catch(() => null);
|
|
432
|
+
if (legacyExisting) {
|
|
433
|
+
await validateExecutableFile(legacyCachePath, 'legacy cached Starkscan binary');
|
|
434
|
+
await mkdir(cacheDir, { recursive: true });
|
|
435
|
+
const stagePath = `${cachePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
436
|
+
try {
|
|
437
|
+
await copyFile(legacyCachePath, stagePath);
|
|
438
|
+
await chmod(stagePath, 0o755);
|
|
439
|
+
await rename(stagePath, cachePath);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
await rm(stagePath, { force: true }).catch(() => {});
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
await validateExecutableFile(cachePath, 'cached Starkscan binary');
|
|
445
|
+
return cachePath;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const releaseRepo = cliEnv('RELEASE_REPO');
|
|
450
|
+
const repo = releaseRepo ?? DEFAULT_REPO;
|
|
451
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
|
|
452
|
+
fail(`unsupported GitHub repo path: ${repo}`);
|
|
453
|
+
}
|
|
454
|
+
const releaseBaseUrl = cliEnv('RELEASE_BASE_URL');
|
|
455
|
+
let rootUrl;
|
|
456
|
+
if (
|
|
457
|
+
!releaseBaseUrl &&
|
|
458
|
+
!releaseRepo &&
|
|
459
|
+
await hasBundledArtifacts(releaseTag)
|
|
460
|
+
) {
|
|
461
|
+
rootUrl = bundledArtifactRootUrl(releaseTag);
|
|
462
|
+
} else {
|
|
463
|
+
const baseUrl = releaseBaseUrl ?? `https://github.com/${repo}/releases`;
|
|
464
|
+
rootUrl = releaseRootUrl(baseUrl, releaseTag);
|
|
465
|
+
}
|
|
466
|
+
return installFromRelease({ platform, releaseTag, cachePath, rootUrl });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Deprecated #1117/#1456 compatibility alias. New package consumers should use
|
|
470
|
+
// ensureStarkscanBinary so the public API is Starkscan-named.
|
|
471
|
+
export const ensureMezcalBinary = ensureStarkscanBinary;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const PACKAGE_JSON_PATH = path.join(PACKAGE_ROOT, 'package.json');
|
|
9
|
+
const DEFAULT_BUNDLED_ARTIFACT_ROOT = path.join(PACKAGE_ROOT, 'artifacts');
|
|
10
|
+
const RELEASE_TAG_RE = /^(?=.*[A-Za-z0-9])(?!-)(?!.*\.\.)[A-Za-z0-9._-]+$/;
|
|
11
|
+
const SUPPORTED_PLATFORMS = [
|
|
12
|
+
'darwin-aarch64',
|
|
13
|
+
'darwin-x86_64',
|
|
14
|
+
'linux-aarch64',
|
|
15
|
+
'linux-x86_64',
|
|
16
|
+
];
|
|
17
|
+
const PUBLIC_MANIFEST_FILENAME = 'starkscan-cli-manifest.json';
|
|
18
|
+
const LEGACY_MANIFEST_FILENAME = 'mezcal-cli-manifest.json';
|
|
19
|
+
|
|
20
|
+
function env(name) {
|
|
21
|
+
const value = process.env[name]?.trim();
|
|
22
|
+
return value && value.length > 0 ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function envFirst(primaryName, legacyName) {
|
|
26
|
+
return env(primaryName) ?? env(legacyName);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fail(message) {
|
|
30
|
+
throw new Error(`verify-bundled-artifacts: ${message}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function releaseTagFromVersion(version) {
|
|
34
|
+
return version.startsWith('v') ? version : `v${version}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function sha256File(filePath) {
|
|
38
|
+
return await new Promise((resolve, reject) => {
|
|
39
|
+
const hash = createHash('sha256');
|
|
40
|
+
const input = createReadStream(filePath);
|
|
41
|
+
input.on('error', reject);
|
|
42
|
+
input.on('data', (chunk) => hash.update(chunk));
|
|
43
|
+
input.on('end', () => resolve(hash.digest('hex')));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function assertFile(filePath, label, remediation) {
|
|
48
|
+
const stat = await lstat(filePath).catch(() => null);
|
|
49
|
+
if (!stat?.isFile()) {
|
|
50
|
+
fail(`${label} is missing: ${filePath}. ${remediation}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function main() {
|
|
55
|
+
const packageJson = JSON.parse(await readFile(PACKAGE_JSON_PATH, 'utf8'));
|
|
56
|
+
if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
|
|
57
|
+
fail('package.json version is missing');
|
|
58
|
+
}
|
|
59
|
+
const releaseTag = envFirst('STARKSCAN_CLI_RELEASE_TAG', 'MEZCAL_CLI_RELEASE_TAG') ?? releaseTagFromVersion(packageJson.version);
|
|
60
|
+
if (!RELEASE_TAG_RE.test(releaseTag)) {
|
|
61
|
+
fail(`unsupported release tag: ${releaseTag}`);
|
|
62
|
+
}
|
|
63
|
+
const artifactRoot =
|
|
64
|
+
envFirst('STARKSCAN_CLI_BUNDLED_ARTIFACT_DIR', 'MEZCAL_CLI_BUNDLED_ARTIFACT_DIR') ??
|
|
65
|
+
DEFAULT_BUNDLED_ARTIFACT_ROOT;
|
|
66
|
+
const releaseRoot = path.join(artifactRoot, releaseTag);
|
|
67
|
+
const remediation =
|
|
68
|
+
`Expected bundled artifacts under ${releaseRoot}. ` +
|
|
69
|
+
'Run native CLI packaging for every supported platform, merge the manifest, then run ' +
|
|
70
|
+
'`./webapp/packages/cli/scripts/package-starkscan-cli-npm.sh .artifacts/release/npm-cli` from the repository root.';
|
|
71
|
+
let manifestPath;
|
|
72
|
+
for (const manifestFilename of [PUBLIC_MANIFEST_FILENAME, LEGACY_MANIFEST_FILENAME]) {
|
|
73
|
+
const candidate = path.join(releaseRoot, manifestFilename);
|
|
74
|
+
const stat = await lstat(candidate).catch(() => null);
|
|
75
|
+
if (stat?.isFile()) {
|
|
76
|
+
manifestPath = candidate;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!manifestPath) {
|
|
81
|
+
manifestPath = path.join(releaseRoot, PUBLIC_MANIFEST_FILENAME);
|
|
82
|
+
await assertFile(manifestPath, 'bundled manifest', remediation);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
86
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
87
|
+
fail('bundled manifest root must be an object');
|
|
88
|
+
}
|
|
89
|
+
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.artifacts)) {
|
|
90
|
+
fail('bundled manifest must use schemaVersion=1 and artifacts[]');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const platform of SUPPORTED_PLATFORMS) {
|
|
94
|
+
const acceptedArchives = [`starkscan-${platform}.tar.gz`, `mezcal-${platform}.tar.gz`];
|
|
95
|
+
const matches = manifest.artifacts.filter((artifact) => artifact?.platform === platform);
|
|
96
|
+
if (matches.length !== 1) {
|
|
97
|
+
fail(`manifest must contain exactly one artifact for ${platform}; found ${matches.length}`);
|
|
98
|
+
}
|
|
99
|
+
const artifact = matches[0];
|
|
100
|
+
const archiveName = artifact.archive ?? artifact.name;
|
|
101
|
+
if (!archiveName) {
|
|
102
|
+
fail(`artifact for ${platform} must define archive or name`);
|
|
103
|
+
}
|
|
104
|
+
if (!acceptedArchives.includes(archiveName)) {
|
|
105
|
+
fail(`artifact for ${platform} must be named one of ${acceptedArchives.join(', ')}`);
|
|
106
|
+
}
|
|
107
|
+
if (artifact.archive !== undefined && artifact.name !== undefined && artifact.archive !== artifact.name) {
|
|
108
|
+
fail(`artifact archive and name for ${platform} must match`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof artifact.sha256 !== 'string' || !/^[0-9a-fA-F]{64}$/.test(artifact.sha256)) {
|
|
111
|
+
fail(`artifact for ${platform} has invalid sha256`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const archivePath = path.join(releaseRoot, archiveName);
|
|
115
|
+
const checksumPath = `${archivePath}.sha256`;
|
|
116
|
+
await assertFile(archivePath, archiveName, remediation);
|
|
117
|
+
await assertFile(checksumPath, `${archiveName}.sha256`, remediation);
|
|
118
|
+
|
|
119
|
+
const expectedSha = artifact.sha256.toLowerCase();
|
|
120
|
+
const checksum = (await readFile(checksumPath, 'utf8')).split(/\s+/)[0]?.toLowerCase();
|
|
121
|
+
if (checksum !== expectedSha) {
|
|
122
|
+
fail(`${archiveName}.sha256 does not match manifest sha256`);
|
|
123
|
+
}
|
|
124
|
+
const actualSha = await sha256File(archivePath);
|
|
125
|
+
if (actualSha !== expectedSha) {
|
|
126
|
+
fail(`${archiveName} bytes do not match manifest sha256`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
main().catch((error) => {
|
|
132
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
133
|
+
process.exit(2);
|
|
134
|
+
});
|