@starkscan/cli 0.1.0-alpha.2 → 0.1.0-beta.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/README.md CHANGED
@@ -1,22 +1,33 @@
1
1
  # @starkscan/cli
2
2
 
3
- npm/npx launcher for the prebuilt Starkscan CLI.
3
+ Command-line launcher for Starkscan API, export, and agent workflows.
4
+
5
+ Status: public beta. Use a smoked exact version such as `0.1.0-beta.1` for unattended
6
+ agents or production services.
4
7
 
5
8
  ## Install
6
9
 
7
10
  ```bash
8
- npm install -g @starkscan/cli@alpha
9
- starkscan doctor
11
+ npm install -g @starkscan/cli@0.1.0-beta.1
12
+ starkscan init
10
13
  ```
11
14
 
12
15
  One-off agent run:
13
16
 
14
17
  ```bash
15
- npx @starkscan/cli@alpha doctor
18
+ npx -y @starkscan/cli@0.1.0-beta.1 init --agent --output-format json
16
19
  ```
17
20
 
18
21
  For unattended agents or production services, pin a smoked exact version instead
19
- of floating on `@alpha`.
22
+ of floating on `@beta`.
23
+
24
+ ## What it does
25
+
26
+ - Runs the prebuilt native `starkscan` binary without requiring a Rust toolchain.
27
+ - Verifies bundled native archives against release-manifest checksums.
28
+ - Calls the hosted Starkscan API with request IDs and route-class headers.
29
+ - Exposes shell-friendly output for agents, scripts, exports, and operator
30
+ smoke checks.
20
31
 
21
32
  ## Trust status
22
33
 
@@ -34,38 +45,91 @@ to Starkscan docs instead of GitHub.
34
45
 
35
46
  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.
36
47
 
48
+ Agents can inspect the launcher decision without executing the native binary:
49
+
50
+ ```bash
51
+ npx -y @starkscan/cli@0.1.0-beta.1 --launcher-json
52
+ ```
53
+
54
+ `--launcher-json` also works through `STARKSCAN_LAUNCHER_JSON=1` or
55
+ `STARKSCAN_CLI_LAUNCHER_JSON=1`. The JSON includes the package version,
56
+ resolved binary path, release tag, source (`bundled`, `download`, `bin-path`, or
57
+ `unknown` for legacy caches without valid sidecar metadata), cache-hit status, and
58
+ verification status. It may resolve and verify the binary first, but it does
59
+ not invoke the native `starkscan` executable.
60
+
37
61
  MCP client setup uses the same package. `print-config` emits machine-readable
38
62
  Codex and Claude Code snippets with environment-variable placeholders, not
39
63
  secret values. `start` is the stable alias for serving the remote stdio bridge:
40
64
 
41
65
  ```bash
42
- npx -y @starkscan/cli@alpha mcp print-config --transport remote
43
- npx -y @starkscan/cli@alpha mcp start --transport remote
66
+ npx -y @starkscan/cli@0.1.0-beta.1 init --agent --output-format json
67
+ npx -y @starkscan/cli@0.1.0-beta.1 mcp print-config --transport remote
68
+ npx -y @starkscan/cli@0.1.0-beta.1 mcp start --transport remote
44
69
  ```
45
70
 
46
71
  ## Environment
47
72
 
48
73
  ```bash
49
- # Use the URL prefix that directly serves `/v1/...`.
50
- # Some public hosts may expose the API under `/api`; do not include `/v1`.
51
- export STARKSCAN_BASE_URL="https://REPLACE_WITH_STARKSCAN_API_HOST"
52
74
  export STARKSCAN_API_KEY="mzk_test_your_key_here"
53
75
  export STARKSCAN_CHAIN="SN_MAIN"
76
+ # Optional: only set this for preview or self-hosted hosts.
77
+ # export STARKSCAN_BASE_URL="https://preview.example.com/api"
54
78
  ```
55
79
 
56
80
  `STARKSCAN_*` is the public CLI environment contract. The launcher still accepts
57
81
  the old internal variable names as hidden compatibility aliases during the beta
58
- cutover. The CLI sends `X-Starkscan-Api-Key` to the hosted API.
82
+ cutover. The CLI defaults to `https://api.starkscan.co` and sends
83
+ `X-Starkscan-Api-Key` to the hosted API.
84
+
85
+ The native CLI forwards public API keys only to Starkscan HTTPS hosts or
86
+ localhost/loopback fixtures. When intentionally testing a private API host, pass
87
+ `--allow-untrusted-base-url` explicitly so keys are not replayed to an
88
+ unexpected endpoint by default.
89
+
90
+ Supported `STARKSCAN_CHAIN` values are `SN_MAIN` and `SN_SEPOLIA`. Global flags
91
+ can be placed before or after the subcommand, for example
92
+ `starkscan status --output-format json --pretty`.
93
+
94
+ For agent callers, `--output-format json` always keeps stdout parseable: success
95
+ prints `{ "ok": true, "data": ... }`, and failures before a result payload is
96
+ emitted print
97
+ `{ "ok": false, "error": { "code": "auth_error", "message": "HTTP 401: unauthorized", "exitCode": 3, "exitClass": "auth", "httpStatus": 401, "requestId": "starkscan-cli-..." } }`
98
+ before exiting non-zero. Diagnostic commands such as `doctor` can emit
99
+ `{ "ok": false, "data": ... }` with a non-zero exit. `stream-json` failures
100
+ print one compact `type: "error"` line. Text mode keeps human-readable errors on
101
+ stderr.
102
+
103
+ Transaction hashes, addresses, token addresses, and calldata values must be
104
+ `0x`-prefixed Starknet field elements. Contract-read selectors can be function
105
+ names such as `balanceOf` or selector felts. Malformed values fail locally before
106
+ any network request with exit code `2`; JSON mode emits `code: "usage_error"`.
107
+ In JSON output, address-shaped fields such as `address`, `fromAddress`,
108
+ `toAddress`, `contractAddress`, and `tokenAddress` are canonicalized to lowercase
109
+ `0x` plus 64 hex characters so agents can join rows reliably. Hashes, calldata,
110
+ selectors, and other felt values are not rewritten.
111
+
112
+ CLI exit codes are stable for agents: `0` success, `1` runtime, `2`
113
+ usage/bad input, `3` auth, `4` rate-limited, `5` timeout, and `6` not found.
114
+
115
+ ## Minimal commands
116
+
117
+ ```bash
118
+ npx -y @starkscan/cli@0.1.0-beta.1 init
119
+ npx -y @starkscan/cli@0.1.0-beta.1 status
120
+ npx -y @starkscan/cli@0.1.0-beta.1 doctor
121
+ npx -y @starkscan/cli@0.1.0-beta.1 mcp print-config --transport remote
122
+ ```
59
123
 
60
124
  ## Release binding
61
125
 
62
126
  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:
63
127
 
64
128
  - `STARKSCAN_CLI_RELEASE_TAG`: release tag, or `latest`
65
- - `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`
66
- - `STARKSCAN_CLI_RELEASE_BASE_URL`: maintainer/test release base URL override. Published public packages use bundled artifacts first; beta clients should not set this.
129
+ - `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 -y @starkscan/cli@0.1.0-beta.1 doctor`
130
+ - `STARKSCAN_CLI_RELEASE_BASE_URL`: maintainer/test release base URL override. Published public packages use bundled artifacts first; beta clients should not set this. By default the override must be a local `file:` URL, the official GitHub HTTPS release path, or Starkscan HTTPS. Custom HTTPS hosts or GitHub repo paths require `STARKSCAN_CLI_ALLOW_CUSTOM_RELEASE_BASE_URL=1`; plaintext HTTP requires `STARKSCAN_CLI_ALLOW_INSECURE_RELEASE_BASE_URL=1` and is limited to localhost or loopback maintainer fixtures. Network release manifests are fail-closed unless `STARKSCAN_CLI_ALLOW_UNSIGNED_NETWORK_MANIFEST=1` is set for a maintainer-only fallback; bundled npm artifacts or local `file:` fixtures are preferred until signed manifest verification exists.
67
131
  - `STARKSCAN_CLI_CACHE_DIR`: cache root for downloaded native binaries
68
- - `STARKSCAN_CLI_BIN_PATH`: use an existing local `starkscan` binary and skip download
132
+ - `STARKSCAN_CLI_BIN_PATH`: maintainer/test override to use an existing local `starkscan` binary and skip download. Requires `STARKSCAN_CLI_ALLOW_BIN_PATH=1`; the path must be absolute, executable, and not a symlink.
69
133
  - `STARKSCAN_CLI_RELEASE_REPO`: maintainer/test GitHub repo path override
70
134
  - `STARKSCAN_CLI_PLATFORM_OVERRIDE`: force a specific target platform for cross-platform testing
71
135
  - `STARKSCAN_CLI_DOWNLOAD_TIMEOUT_MS`: download inactivity timeout in milliseconds, default `120000`
@@ -1,34 +1,34 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-06-04T11:06:41Z",
3
+ "generatedAt": "2026-06-29T04:53:36Z",
4
4
  "artifacts": [
5
5
  {
6
6
  "name": "starkscan-darwin-aarch64.tar.gz",
7
7
  "platform": "darwin-aarch64",
8
8
  "target": "darwin-aarch64",
9
9
  "archive": "starkscan-darwin-aarch64.tar.gz",
10
- "sha256": "a7b841e723af2c89269ca28b461ca6ef94b3cda329a38076356837225e649496"
10
+ "sha256": "b33a14b7b4e85ee571ee537e46153ea0ef5eb1eb01d6d34c3620c670c13cb371"
11
11
  },
12
12
  {
13
13
  "name": "starkscan-darwin-x86_64.tar.gz",
14
14
  "platform": "darwin-x86_64",
15
15
  "target": "darwin-x86_64",
16
16
  "archive": "starkscan-darwin-x86_64.tar.gz",
17
- "sha256": "c99ddc5b28f6dcfab8523a6c13ab629fbc38a772c001bb75320490cb83fef382"
17
+ "sha256": "4bcf906defb317754d38c72a9586219eadfcb9dc725aa920a4fce650c78cf311"
18
18
  },
19
19
  {
20
20
  "name": "starkscan-linux-aarch64.tar.gz",
21
21
  "platform": "linux-aarch64",
22
22
  "target": "linux-aarch64",
23
23
  "archive": "starkscan-linux-aarch64.tar.gz",
24
- "sha256": "b41ed5a797590eab738a63999b64244e18a546d3ffb4b7659e3e67f81f894597"
24
+ "sha256": "a60a493fd6c226b329d9e7a4f1b1c1a0d62a68d59740dd484390306db879bac1"
25
25
  },
26
26
  {
27
27
  "name": "starkscan-linux-x86_64.tar.gz",
28
28
  "platform": "linux-x86_64",
29
29
  "target": "linux-x86_64",
30
30
  "archive": "starkscan-linux-x86_64.tar.gz",
31
- "sha256": "604970a343d948d426f5482630ac36cd42eb56989f1b3c6eae9113e0bc1584de"
31
+ "sha256": "604f25e483adf6b035e37ef803f6e5d5a56bed20b3caa1762d0a46923d384e05"
32
32
  }
33
33
  ]
34
34
  }
@@ -0,0 +1 @@
1
+ b33a14b7b4e85ee571ee537e46153ea0ef5eb1eb01d6d34c3620c670c13cb371 starkscan-darwin-aarch64.tar.gz
@@ -0,0 +1 @@
1
+ 4bcf906defb317754d38c72a9586219eadfcb9dc725aa920a4fce650c78cf311 starkscan-darwin-x86_64.tar.gz
@@ -0,0 +1 @@
1
+ a60a493fd6c226b329d9e7a4f1b1c1a0d62a68d59740dd484390306db879bac1 starkscan-linux-aarch64.tar.gz
@@ -0,0 +1 @@
1
+ 604f25e483adf6b035e37ef803f6e5d5a56bed20b3caa1762d0a46923d384e05 starkscan-linux-x86_64.tar.gz
package/bin/starkscan.js CHANGED
@@ -2,7 +2,18 @@
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { constants as osConstants } from 'node:os';
4
4
 
5
- import { ensureStarkscanBinary } from '../scripts/installer.mjs';
5
+ import { ensureStarkscanBinary, resolveStarkscanBinary } from '../scripts/installer.mjs';
6
+
7
+ function truthyEnv(name) {
8
+ const value = process.env[name]?.trim().toLowerCase();
9
+ return value === '1' || value === 'true' || value === 'yes' || value === 'on';
10
+ }
11
+
12
+ function shouldPrintLauncherJson(args) {
13
+ return args.includes('--launcher-json')
14
+ || truthyEnv('STARKSCAN_LAUNCHER_JSON')
15
+ || truthyEnv('STARKSCAN_CLI_LAUNCHER_JSON');
16
+ }
6
17
 
7
18
  function exitFromResult(result) {
8
19
  if (result.error) {
@@ -24,8 +35,15 @@ function exitFromResult(result) {
24
35
  }
25
36
 
26
37
  try {
38
+ const args = process.argv.slice(2);
39
+ if (shouldPrintLauncherJson(args)) {
40
+ const resolution = await resolveStarkscanBinary();
41
+ process.stdout.write(`${JSON.stringify(resolution, null, 2)}\n`);
42
+ process.exit(0);
43
+ }
44
+
27
45
  const binaryPath = await ensureStarkscanBinary();
28
- const result = spawnSync(binaryPath, process.argv.slice(2), {
46
+ const result = spawnSync(binaryPath, args, {
29
47
  stdio: 'inherit',
30
48
  });
31
49
  exitFromResult(result);
package/package.json CHANGED
@@ -1,20 +1,31 @@
1
1
  {
2
2
  "name": "@starkscan/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.2",
5
- "description": "npm/npx launcher for the prebuilt Starkscan explorer CLI.",
4
+ "version": "0.1.0-beta.1",
5
+ "description": "Command-line launcher for Starkscan API, export, and agent workflows.",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "publishConfig": {
9
9
  "access": "public"
10
10
  },
11
11
  "homepage": "https://starkscan.co/docs/ai/agent-cli",
12
+ "bugs": {
13
+ "url": "https://starkscan.co/docs/build/package-trust"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/starknet-innovation/mezcal",
18
+ "directory": "webapp/packages/cli"
19
+ },
12
20
  "keywords": [
13
21
  "starkscan",
14
22
  "starknet",
15
- "explorer",
23
+ "block-explorer",
24
+ "cairo",
25
+ "web3",
16
26
  "cli",
17
- "agent"
27
+ "agent",
28
+ "npx"
18
29
  ],
19
30
  "files": [
20
31
  "artifacts",
@@ -32,9 +43,11 @@
32
43
  "prepack": "npm run verify:bundled && npm run typecheck && npm test",
33
44
  "prepublishOnly": "npm run verify:bundled && npm run typecheck && npm test",
34
45
  "publish:alpha:dry-run": "./scripts/publish-public-cli.sh --tag alpha --dry-run",
35
- "test": "node ./scripts/tests/cli-wrapper.mjs && ./scripts/tests/publish-public-cli.sh",
36
- "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",
37
- "verify:bundled": "node ./scripts/verify-bundled-artifacts.mjs"
46
+ "test": "node ./scripts/tests/cli-wrapper.mjs && ./scripts/tests/publish-public-cli.sh && ./scripts/tests/e2e-canary-channel.sh",
47
+ "test:e2e": "node ./scripts/tests/e2e-canary.mjs",
48
+ "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 && node --check ./scripts/tests/e2e-canary.mjs",
49
+ "verify:bundled": "node ./scripts/verify-bundled-artifacts.mjs",
50
+ "publish:beta:dry-run": "./scripts/publish-public-cli.sh --tag beta --dry-run"
38
51
  },
39
52
  "engines": {
40
53
  "node": ">=18"
@@ -1,9 +1,10 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
3
  import { createReadStream, createWriteStream } from 'node:fs';
4
- import { copyFile, chmod, lstat, mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises';
4
+ import { copyFile, chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
5
5
  import { get as httpGet } from 'node:http';
6
6
  import { get as httpsGet } from 'node:https';
7
+ import { isIP } from 'node:net';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
9
10
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -11,10 +12,15 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
11
12
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
13
  const PACKAGE_JSON_PATH = path.join(PACKAGE_ROOT, 'package.json');
13
14
  const DEFAULT_BUNDLED_ARTIFACT_ROOT = path.join(PACKAGE_ROOT, 'artifacts');
14
- const DEFAULT_REPO = 'starknet-innovation/starkscan';
15
+ const DEFAULT_REPO = 'starknet-innovation/mezcal';
15
16
  const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
16
17
  const MAX_REDIRECTS = 10;
17
18
  const RELEASE_TAG_RE = /^(?=.*[A-Za-z0-9])(?!-)(?!.*\.\.)[A-Za-z0-9._-]+$/;
19
+ const ALLOWED_RELEASE_BASE_HOSTS = new Set(['github.com', 'starkscan.co']);
20
+ const ALLOWED_RELEASE_REDIRECT_HOSTS = new Set([
21
+ 'objects.githubusercontent.com',
22
+ 'release-assets.githubusercontent.com',
23
+ ]);
18
24
  const SUPPORTED_PLATFORMS = new Set([
19
25
  'darwin-aarch64',
20
26
  'darwin-x86_64',
@@ -23,6 +29,10 @@ const SUPPORTED_PLATFORMS = new Set([
23
29
  ]);
24
30
  const PUBLIC_BINARY_NAME = 'starkscan';
25
31
  const PUBLIC_MANIFEST_FILENAME = 'starkscan-cli-manifest.json';
32
+ const EXECUTABLE_CHECKS = ['not-symlink', 'regular-file', 'executable'];
33
+ const LOCAL_BIN_PATH_CHECKS = ['absolute-path', ...EXECUTABLE_CHECKS];
34
+ const KNOWN_CACHE_SOURCES = new Set(['bundled', 'download']);
35
+ const MANIFEST_TRUST_CHECKS = new Set(['local-release-manifest', 'unsigned-network-manifest-opt-in']);
26
36
 
27
37
  function env(name) {
28
38
  const value = process.env[name]?.trim();
@@ -37,6 +47,11 @@ function cliEnv(suffix) {
37
47
  return envFirst(`STARKSCAN_CLI_${suffix}`, `MEZCAL_CLI_${suffix}`);
38
48
  }
39
49
 
50
+ function cliFlag(suffix) {
51
+ const value = cliEnv(suffix)?.toLowerCase();
52
+ return value === '1' || value === 'true' || value === 'yes' || value === 'on';
53
+ }
54
+
40
55
  function fail(message) {
41
56
  throw new Error(message);
42
57
  }
@@ -169,8 +184,9 @@ function releaseTagFromVersion(version) {
169
184
  return version.startsWith('v') ? version : `v${version}`;
170
185
  }
171
186
 
172
- async function resolveReleaseTag() {
173
- const tag = cliEnv('RELEASE_TAG') ?? env('MEZCAL_INSTALL_VERSION') ?? releaseTagFromVersion(await packageVersion());
187
+ async function resolveReleaseTag(version) {
188
+ const packageVersionForTag = version ?? await packageVersion();
189
+ const tag = cliEnv('RELEASE_TAG') ?? env('MEZCAL_INSTALL_VERSION') ?? releaseTagFromVersion(packageVersionForTag);
174
190
  if (tag !== 'latest' && !RELEASE_TAG_RE.test(tag)) {
175
191
  fail(`unsupported release tag: ${tag}`);
176
192
  }
@@ -178,17 +194,137 @@ async function resolveReleaseTag() {
178
194
  }
179
195
 
180
196
  function releaseRootUrl(baseUrl, releaseTag) {
181
- const normalizedBase = baseUrl.replace(/\/+$/, '');
197
+ const normalizedBase = normalizeReleaseBaseUrl(baseUrl).replace(/\/+$/, '');
182
198
  if (releaseTag === 'latest') {
183
199
  return `${normalizedBase}/latest/download`;
184
200
  }
185
201
  return `${normalizedBase}/download/${releaseTag}`;
186
202
  }
187
203
 
204
+ function releaseBaseHostIsAllowed(hostname) {
205
+ return ALLOWED_RELEASE_BASE_HOSTS.has(hostname) || hostname.endsWith('.starkscan.co');
206
+ }
207
+
208
+ function releaseRedirectHttpsHostIsAllowed(hostname) {
209
+ return releaseBaseHostIsAllowed(hostname) || ALLOWED_RELEASE_REDIRECT_HOSTS.has(hostname);
210
+ }
211
+
212
+ function releaseBaseHttpHostIsLocal(hostname) {
213
+ const normalized = hostname.toLowerCase();
214
+ if (normalized === 'localhost') {
215
+ return true;
216
+ }
217
+ const address = normalized.startsWith('[') && normalized.endsWith(']')
218
+ ? normalized.slice(1, -1)
219
+ : normalized;
220
+ if (address === '::1') {
221
+ return true;
222
+ }
223
+ if (isIP(address) !== 4) {
224
+ return false;
225
+ }
226
+ const octets = address.split('.').map(Number);
227
+ return octets.length === 4 && octets[0] === 127 && octets.every((octet) => octet >= 0 && octet <= 255);
228
+ }
229
+
230
+ function releaseGitHubPathMatchesDefaultRepo(pathname) {
231
+ const normalized = pathname.replace(/\/+$/, '');
232
+ const [owner, repo, segment, ...extra] = normalized.split('/').filter(Boolean);
233
+ const [expectedOwner, expectedRepo] = DEFAULT_REPO.split('/');
234
+ const matchesRepo =
235
+ owner?.toLowerCase() === expectedOwner.toLowerCase()
236
+ && repo?.toLowerCase() === expectedRepo.toLowerCase()
237
+ && segment === 'releases';
238
+ return { matchesRepo, extra };
239
+ }
240
+
241
+ function releaseBaseGitHubPathIsAllowed(pathname) {
242
+ const { matchesRepo, extra } = releaseGitHubPathMatchesDefaultRepo(pathname);
243
+ return matchesRepo && extra.length === 0;
244
+ }
245
+
246
+ function releaseRedirectGitHubPathIsAllowed(pathname) {
247
+ const { matchesRepo } = releaseGitHubPathMatchesDefaultRepo(pathname);
248
+ return matchesRepo;
249
+ }
250
+
251
+ export function normalizeReleaseBaseUrl(baseUrl) {
252
+ let parsed;
253
+ try {
254
+ parsed = new URL(baseUrl);
255
+ } catch {
256
+ fail(`unsupported STARKSCAN_CLI_RELEASE_BASE_URL: ${baseUrl}`);
257
+ }
258
+ if (parsed.search || parsed.hash) {
259
+ fail('STARKSCAN_CLI_RELEASE_BASE_URL must not include query parameters or fragments');
260
+ }
261
+ if (parsed.username || parsed.password) {
262
+ fail('STARKSCAN_CLI_RELEASE_BASE_URL must not include credentials');
263
+ }
264
+
265
+ if (parsed.protocol === 'file:') {
266
+ try {
267
+ const filePath = fileURLToPath(parsed);
268
+ if (!path.isAbsolute(filePath)) {
269
+ fail('STARKSCAN_CLI_RELEASE_BASE_URL file: override must resolve to an absolute path');
270
+ }
271
+ return pathToFileURL(filePath).href;
272
+ } catch (error) {
273
+ if (error instanceof Error && error.message.includes('STARKSCAN_CLI_RELEASE_BASE_URL')) {
274
+ throw error;
275
+ }
276
+ fail('STARKSCAN_CLI_RELEASE_BASE_URL file: override must be an absolute local file URL');
277
+ }
278
+ }
279
+
280
+ if (parsed.protocol === 'http:') {
281
+ if (!cliFlag('ALLOW_INSECURE_RELEASE_BASE_URL')) {
282
+ fail('STARKSCAN_CLI_RELEASE_BASE_URL must use HTTPS or file: unless STARKSCAN_CLI_ALLOW_INSECURE_RELEASE_BASE_URL=1 is set for a local maintainer test');
283
+ }
284
+ if (!releaseBaseHttpHostIsLocal(parsed.hostname)) {
285
+ fail('plaintext HTTP STARKSCAN_CLI_RELEASE_BASE_URL is only allowed for localhost or loopback maintainer fixtures');
286
+ }
287
+ return parsed.href;
288
+ }
289
+
290
+ if (parsed.protocol !== 'https:') {
291
+ fail(`unsupported STARKSCAN_CLI_RELEASE_BASE_URL protocol: ${parsed.protocol}`);
292
+ }
293
+
294
+ if (!releaseBaseHostIsAllowed(parsed.hostname) && !cliFlag('ALLOW_CUSTOM_RELEASE_BASE_URL')) {
295
+ fail(`custom STARKSCAN_CLI_RELEASE_BASE_URL host is not allowed by default: ${parsed.hostname}`);
296
+ }
297
+
298
+ if (parsed.hostname === 'github.com' && !cliFlag('ALLOW_CUSTOM_RELEASE_BASE_URL') && !releaseBaseGitHubPathIsAllowed(parsed.pathname)) {
299
+ fail(`custom STARKSCAN_CLI_RELEASE_BASE_URL GitHub repository is not allowed by default: ${parsed.pathname}`);
300
+ }
301
+
302
+ return parsed.href;
303
+ }
304
+
188
305
  function releaseAssetUrl(rootUrl, filename) {
189
306
  return `${rootUrl}/${filename}`;
190
307
  }
191
308
 
309
+ function releaseRootUsesNetwork(rootUrl) {
310
+ const protocol = new URL(rootUrl).protocol;
311
+ return protocol === 'https:' || protocol === 'http:';
312
+ }
313
+
314
+ function releaseManifestTrustCheck(rootUrl) {
315
+ if (!releaseRootUsesNetwork(rootUrl)) {
316
+ return 'local-release-manifest';
317
+ }
318
+ if (!cliFlag('ALLOW_UNSIGNED_NETWORK_MANIFEST')) {
319
+ fail(
320
+ 'unsigned network release manifests are not trusted by default; use bundled npm artifacts, '
321
+ + 'a local file: release source, or set STARKSCAN_CLI_ALLOW_UNSIGNED_NETWORK_MANIFEST=1 '
322
+ + 'for a maintainer-only fallback until signed manifest verification is available',
323
+ );
324
+ }
325
+ return 'unsigned-network-manifest-opt-in';
326
+ }
327
+
192
328
  function bundledArtifactRoot() {
193
329
  const configured = cliEnv('BUNDLED_ARTIFACT_DIR');
194
330
  if (!configured) {
@@ -207,6 +343,127 @@ async function hasBundledArtifacts(releaseTag) {
207
343
  return Boolean(stat?.isFile());
208
344
  }
209
345
 
346
+ async function resolveReleaseSource(releaseTag) {
347
+ const releaseRepo = cliEnv('RELEASE_REPO');
348
+ const repo = releaseRepo ?? DEFAULT_REPO;
349
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
350
+ fail(`unsupported GitHub repo path: ${repo}`);
351
+ }
352
+
353
+ const releaseBaseUrl = cliEnv('RELEASE_BASE_URL');
354
+ if (
355
+ !releaseBaseUrl &&
356
+ !releaseRepo &&
357
+ await hasBundledArtifacts(releaseTag)
358
+ ) {
359
+ return {
360
+ rootUrl: bundledArtifactRootUrl(releaseTag),
361
+ source: 'bundled',
362
+ requestedFrom: 'bundled-artifacts',
363
+ };
364
+ }
365
+
366
+ const baseUrl = releaseBaseUrl ?? `https://github.com/${repo}/releases`;
367
+ return {
368
+ rootUrl: releaseRootUrl(baseUrl, releaseTag),
369
+ source: 'download',
370
+ requestedFrom: releaseBaseUrl ? 'release-base-url' : 'github-release',
371
+ };
372
+ }
373
+
374
+ function cacheMetadataPath(cachePath) {
375
+ return `${cachePath}.meta.json`;
376
+ }
377
+
378
+ async function writeCacheMetadata(cachePath, metadata) {
379
+ const metadataPath = cacheMetadataPath(cachePath);
380
+ const stagePath = `${metadataPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
381
+ try {
382
+ await writeFile(stagePath, `${JSON.stringify({ schemaVersion: 1, ...metadata }, null, 2)}\n`);
383
+ await rename(stagePath, metadataPath);
384
+ } catch (error) {
385
+ await rm(stagePath, { force: true }).catch(() => {});
386
+ throw error;
387
+ }
388
+ }
389
+
390
+ async function readCacheMetadata(cachePath, releaseTag, platform) {
391
+ const metadataPath = cacheMetadataPath(cachePath);
392
+ try {
393
+ const parsed = JSON.parse(await readFile(metadataPath, 'utf8'));
394
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
395
+ throw new Error('cache metadata root must be an object');
396
+ }
397
+ if (parsed.schemaVersion !== 1) {
398
+ throw new Error('cache metadata schemaVersion must be 1');
399
+ }
400
+ if (parsed.releaseTag !== releaseTag || parsed.platform !== platform) {
401
+ throw new Error('cache metadata does not match cache path');
402
+ }
403
+ if (typeof parsed.source !== 'string' || !KNOWN_CACHE_SOURCES.has(parsed.source)) {
404
+ throw new Error('cache metadata source is invalid');
405
+ }
406
+ const storedChecks = Array.isArray(parsed.verification?.checks)
407
+ ? parsed.verification.checks.filter((check) => typeof check === 'string')
408
+ : [];
409
+ const manifestTrustChecks = storedChecks.filter((check) => MANIFEST_TRUST_CHECKS.has(check));
410
+ return {
411
+ source: parsed.source,
412
+ requestedFrom: typeof parsed.requestedFrom === 'string' ? parsed.requestedFrom : 'cache-metadata',
413
+ releaseRootUrl: typeof parsed.releaseRootUrl === 'string' ? parsed.releaseRootUrl : null,
414
+ manifestTrustChecks,
415
+ sourceConfidence: 'stored',
416
+ metadataStatus: 'stored',
417
+ };
418
+ } catch (error) {
419
+ const errorCode = error && typeof error === 'object' && 'code' in error ? String(error.code) : undefined;
420
+ const isMissing = errorCode === 'ENOENT';
421
+ return {
422
+ source: 'unknown',
423
+ requestedFrom: isMissing ? 'legacy-cache-missing-metadata' : 'cache-metadata-invalid',
424
+ releaseRootUrl: null,
425
+ manifestTrustChecks: [],
426
+ sourceConfidence: 'unknown',
427
+ metadataStatus: isMissing ? 'missing' : 'invalid',
428
+ metadataError: isMissing ? undefined : (error instanceof SyntaxError ? 'invalid-json' : errorCode ?? 'invalid-metadata'),
429
+ };
430
+ }
431
+ }
432
+
433
+ function enforceCachedManifestTrustChecks(cachedSource) {
434
+ const manifestTrustChecks = cachedSource.manifestTrustChecks ?? [];
435
+ if (cachedSource.metadataStatus !== 'stored') {
436
+ fail(
437
+ 'cached Starkscan binary is missing trusted cache metadata; delete the cache and reinstall '
438
+ + 'from bundled npm artifacts or a local file: release source',
439
+ );
440
+ }
441
+ if (manifestTrustChecks.length === 0) {
442
+ fail(
443
+ 'cached Starkscan binary is missing release manifest trust provenance; delete the cache and reinstall '
444
+ + 'from bundled npm artifacts or a local file: release source',
445
+ );
446
+ }
447
+ if (
448
+ manifestTrustChecks.includes('unsigned-network-manifest-opt-in') &&
449
+ !cliFlag('ALLOW_UNSIGNED_NETWORK_MANIFEST')
450
+ ) {
451
+ fail(
452
+ 'cached Starkscan binary was installed from an unsigned network release manifest; '
453
+ + 'set STARKSCAN_CLI_ALLOW_UNSIGNED_NETWORK_MANIFEST=1 to reuse this maintainer-only '
454
+ + 'fallback cache, or delete the cache and reinstall from bundled npm artifacts or a local file: release source',
455
+ );
456
+ }
457
+ }
458
+
459
+ function launcherResolution(fields) {
460
+ return {
461
+ schemaVersion: 1,
462
+ packageName: '@starkscan/cli',
463
+ ...fields,
464
+ };
465
+ }
466
+
210
467
  export function resolveRedirectUrl(url, location, redirectsLeft) {
211
468
  if (redirectsLeft <= 0) {
212
469
  fail(`too many redirects downloading ${url}`);
@@ -219,6 +476,27 @@ export function resolveRedirectUrl(url, location, redirectsLeft) {
219
476
  if (currentUrl.protocol === 'https:' && redirectUrl.protocol === 'http:') {
220
477
  fail(`redirect from HTTPS to HTTP is not allowed: ${redirectUrl.toString()}`);
221
478
  }
479
+ if (redirectUrl.username || redirectUrl.password) {
480
+ fail('release asset redirect URL must not include credentials');
481
+ }
482
+ if (redirectUrl.protocol === 'http:' && !releaseBaseHttpHostIsLocal(redirectUrl.hostname)) {
483
+ fail('plaintext HTTP release asset redirect is only allowed for localhost or loopback maintainer fixtures');
484
+ }
485
+ if (
486
+ redirectUrl.protocol === 'https:' &&
487
+ !releaseRedirectHttpsHostIsAllowed(redirectUrl.hostname) &&
488
+ !cliFlag('ALLOW_CUSTOM_RELEASE_BASE_URL')
489
+ ) {
490
+ fail(`custom release asset redirect host is not allowed by default: ${redirectUrl.hostname}`);
491
+ }
492
+ if (
493
+ redirectUrl.protocol === 'https:' &&
494
+ redirectUrl.hostname === 'github.com' &&
495
+ !cliFlag('ALLOW_CUSTOM_RELEASE_BASE_URL') &&
496
+ !releaseRedirectGitHubPathIsAllowed(redirectUrl.pathname)
497
+ ) {
498
+ fail(`custom release asset redirect GitHub repository is not allowed by default: ${redirectUrl.pathname}`);
499
+ }
222
500
  return redirectUrl.toString();
223
501
  }
224
502
 
@@ -351,9 +629,10 @@ async function readManifest(rootUrl, tempDir) {
351
629
  return await readDownloadedManifest(manifestPath, PUBLIC_MANIFEST_FILENAME);
352
630
  }
353
631
 
354
- async function installFromRelease({ platform, releaseTag, cachePath, rootUrl }) {
632
+ async function installFromRelease({ platform, releaseTag, cachePath, rootUrl, source, requestedFrom }) {
355
633
  const tempDir = await mkdtemp(path.join(os.tmpdir(), 'starkscan-npm-cli-'));
356
634
  try {
635
+ const manifestTrustCheck = releaseManifestTrustCheck(rootUrl);
357
636
  const manifest = await readManifest(rootUrl, tempDir);
358
637
  const matching = manifest.artifacts.filter((artifact) => artifact?.platform === platform);
359
638
  if (matching.length !== 1) {
@@ -391,21 +670,75 @@ async function installFromRelease({ platform, releaseTag, cachePath, rootUrl })
391
670
  throw error;
392
671
  }
393
672
  await validateExecutableFile(cachePath, 'cached Starkscan binary');
394
- return cachePath;
673
+ const result = {
674
+ binaryPath: cachePath,
675
+ platform,
676
+ releaseTag,
677
+ source,
678
+ requestedFrom,
679
+ resolvedFrom: 'release',
680
+ cacheHit: false,
681
+ cachePath,
682
+ releaseRootUrl: rootUrl,
683
+ verification: {
684
+ status: 'verified-sha256',
685
+ manifest: PUBLIC_MANIFEST_FILENAME,
686
+ archive: artifact.archive,
687
+ sha256: artifact.sha256,
688
+ checks: [manifestTrustCheck, 'manifest-schema', 'platform-match', 'archive-name', 'archive-sha256', 'single-binary-archive', ...EXECUTABLE_CHECKS],
689
+ },
690
+ };
691
+ await writeCacheMetadata(cachePath, {
692
+ platform,
693
+ releaseTag,
694
+ source,
695
+ requestedFrom,
696
+ releaseRootUrl: rootUrl,
697
+ verification: {
698
+ status: result.verification.status,
699
+ manifest: result.verification.manifest,
700
+ archive: result.verification.archive,
701
+ sha256: result.verification.sha256,
702
+ checks: result.verification.checks,
703
+ },
704
+ });
705
+ return result;
395
706
  } finally {
396
707
  await rm(tempDir, { recursive: true, force: true });
397
708
  }
398
709
  }
399
710
 
400
- export async function ensureStarkscanBinary(options = {}) {
711
+ export async function resolveStarkscanBinary(options = {}) {
712
+ const version = await packageVersion();
401
713
  const explicitBinary = cliEnv('BIN_PATH');
402
714
  if (explicitBinary) {
715
+ if (!cliFlag('ALLOW_BIN_PATH')) {
716
+ fail('STARKSCAN_CLI_BIN_PATH requires STARKSCAN_CLI_ALLOW_BIN_PATH=1');
717
+ }
718
+ if (!path.isAbsolute(explicitBinary)) {
719
+ fail(`STARKSCAN_CLI_BIN_PATH must be an absolute path: ${explicitBinary}`);
720
+ }
403
721
  await validateExecutableFile(explicitBinary, 'STARKSCAN_CLI_BIN_PATH');
404
- return explicitBinary;
722
+ return launcherResolution({
723
+ packageVersion: version,
724
+ binaryPath: explicitBinary,
725
+ platform: null,
726
+ releaseTag: null,
727
+ source: 'bin-path',
728
+ requestedFrom: 'bin-path',
729
+ resolvedFrom: 'bin-path',
730
+ cacheHit: false,
731
+ cachePath: null,
732
+ releaseRootUrl: null,
733
+ verification: {
734
+ status: 'validated-local-executable',
735
+ checks: LOCAL_BIN_PATH_CHECKS,
736
+ },
737
+ });
405
738
  }
406
739
 
407
740
  const platform = resolvePlatform();
408
- const releaseTag = await resolveReleaseTag();
741
+ const releaseTag = await resolveReleaseTag(version);
409
742
  const cacheDir = path.join(defaultCacheRoot(), releaseTag, platform);
410
743
  const cachePath = path.join(cacheDir, PUBLIC_BINARY_NAME);
411
744
  const allowCacheReuse = !options.force && releaseTag !== 'latest';
@@ -413,26 +746,45 @@ export async function ensureStarkscanBinary(options = {}) {
413
746
  const existing = await lstat(cachePath).catch(() => null);
414
747
  if (existing) {
415
748
  await validateExecutableFile(cachePath, 'cached Starkscan binary');
416
- return cachePath;
749
+ const cachedSource = await readCacheMetadata(cachePath, releaseTag, platform);
750
+ enforceCachedManifestTrustChecks(cachedSource);
751
+ return launcherResolution({
752
+ packageVersion: version,
753
+ binaryPath: cachePath,
754
+ platform,
755
+ releaseTag,
756
+ source: cachedSource.source,
757
+ requestedFrom: cachedSource.requestedFrom,
758
+ resolvedFrom: 'cache',
759
+ cacheHit: true,
760
+ cachePath,
761
+ releaseRootUrl: cachedSource.releaseRootUrl,
762
+ sourceConfidence: cachedSource.sourceConfidence,
763
+ metadataStatus: cachedSource.metadataStatus,
764
+ ...(cachedSource.metadataError ? { metadataError: cachedSource.metadataError } : {}),
765
+ verification: {
766
+ status: 'validated-cached-executable',
767
+ checks: [...(cachedSource.manifestTrustChecks ?? []), ...EXECUTABLE_CHECKS],
768
+ },
769
+ });
417
770
  }
418
771
  }
419
772
 
420
- const releaseRepo = cliEnv('RELEASE_REPO');
421
- const repo = releaseRepo ?? DEFAULT_REPO;
422
- if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
423
- fail(`unsupported GitHub repo path: ${repo}`);
424
- }
425
- const releaseBaseUrl = cliEnv('RELEASE_BASE_URL');
426
- let rootUrl;
427
- if (
428
- !releaseBaseUrl &&
429
- !releaseRepo &&
430
- await hasBundledArtifacts(releaseTag)
431
- ) {
432
- rootUrl = bundledArtifactRootUrl(releaseTag);
433
- } else {
434
- const baseUrl = releaseBaseUrl ?? `https://github.com/${repo}/releases`;
435
- rootUrl = releaseRootUrl(baseUrl, releaseTag);
436
- }
437
- return installFromRelease({ platform, releaseTag, cachePath, rootUrl });
773
+ const releaseSource = await resolveReleaseSource(releaseTag);
774
+ return launcherResolution({
775
+ packageVersion: version,
776
+ ...await installFromRelease({
777
+ platform,
778
+ releaseTag,
779
+ cachePath,
780
+ rootUrl: releaseSource.rootUrl,
781
+ source: releaseSource.source,
782
+ requestedFrom: releaseSource.requestedFrom,
783
+ }),
784
+ });
785
+ }
786
+
787
+ export async function ensureStarkscanBinary(options = {}) {
788
+ const resolution = await resolveStarkscanBinary(options);
789
+ return resolution.binaryPath;
438
790
  }
@@ -1,6 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { createReadStream } from 'node:fs';
3
- import { lstat, readFile } from 'node:fs/promises';
4
+ import { lstat, mkdtemp, readFile, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
4
6
  import path from 'node:path';
5
7
  import { fileURLToPath } from 'node:url';
6
8
 
@@ -15,6 +17,14 @@ const SUPPORTED_PLATFORMS = [
15
17
  'linux-x86_64',
16
18
  ];
17
19
  const PUBLIC_MANIFEST_FILENAME = 'starkscan-cli-manifest.json';
20
+ const PUBLIC_BINARY_NAME = 'starkscan';
21
+ const HOSTED_DEFAULT_BASE_URL = 'https://api.starkscan.co';
22
+ const PUBLIC_HELP_FORBIDDEN_STRINGS = [
23
+ 'http://127.0.0.1:3000',
24
+ '--internal-api-key',
25
+ 'STARKSCAN_INTERNAL_API_KEY',
26
+ 'X-Explorer-Internal-Key',
27
+ ];
18
28
 
19
29
  function env(name) {
20
30
  const value = process.env[name]?.trim();
@@ -29,6 +39,26 @@ function fail(message) {
29
39
  throw new Error(`verify-bundled-artifacts: ${message}`);
30
40
  }
31
41
 
42
+ function describeSpawn(result) {
43
+ const details = [
44
+ `status=${result.status ?? 'null'}`,
45
+ `signal=${result.signal ?? 'none'}`,
46
+ ];
47
+ if (result.error?.message) {
48
+ details.push(`error=${result.error.message}`);
49
+ }
50
+ for (const [name, value] of [
51
+ ['stdout', result.stdout],
52
+ ['stderr', result.stderr],
53
+ ]) {
54
+ const text = String(value ?? '').trim();
55
+ if (text.length > 0) {
56
+ details.push(`${name}=${text}`);
57
+ }
58
+ }
59
+ return details.join('; ');
60
+ }
61
+
32
62
  function releaseTagFromVersion(version) {
33
63
  return version.startsWith('v') ? version : `v${version}`;
34
64
  }
@@ -50,6 +80,97 @@ async function assertFile(filePath, label, remediation) {
50
80
  }
51
81
  }
52
82
 
83
+ function currentReleasePlatform() {
84
+ const os =
85
+ process.platform === 'darwin' || process.platform === 'linux'
86
+ ? process.platform
87
+ : undefined;
88
+ const arch =
89
+ process.arch === 'x64' ? 'x86_64' : process.arch === 'arm64' ? 'aarch64' : undefined;
90
+ return os && arch ? `${os}-${arch}` : undefined;
91
+ }
92
+
93
+ function publicVerifierSpawnEnv() {
94
+ const nextEnv = {};
95
+ for (const name of ['PATH', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL', 'LC_CTYPE']) {
96
+ const value = process.env[name];
97
+ if (typeof value === 'string' && value.length > 0) {
98
+ nextEnv[name] = value;
99
+ }
100
+ }
101
+ if (!nextEnv.PATH) {
102
+ nextEnv.PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
103
+ }
104
+ return nextEnv;
105
+ }
106
+
107
+ async function verifyCurrentPlatformHelpContract(releaseRoot, manifest) {
108
+ const platform = currentReleasePlatform();
109
+ if (!platform) return;
110
+ const artifact = manifest.artifacts.find((candidate) => candidate?.platform === platform);
111
+ if (!artifact) {
112
+ fail(`bundled manifest is missing current platform artifact ${platform}`);
113
+ }
114
+ const archiveName = artifact.archive ?? artifact.name;
115
+ const archivePath = path.join(releaseRoot, archiveName);
116
+ const tmpRoot = await mkdtemp(path.join(tmpdir(), 'starkscan-cli-contract-'));
117
+ try {
118
+ const listingResult = spawnSync('tar', ['-tzf', archivePath], {
119
+ encoding: 'utf8',
120
+ env: publicVerifierSpawnEnv(),
121
+ timeout: 15_000,
122
+ });
123
+ if (listingResult.status !== 0 || listingResult.error) {
124
+ fail(`failed to inspect current-platform CLI artifact ${archiveName}: ${describeSpawn(listingResult)}`);
125
+ }
126
+ const listing = listingResult.stdout
127
+ .split(/\r?\n/)
128
+ .map((entry) => entry.trim())
129
+ .filter(Boolean);
130
+ if (listing.length !== 1 || listing[0] !== PUBLIC_BINARY_NAME) {
131
+ fail(
132
+ `current-platform CLI artifact ${archiveName} must contain exactly one top-level Starkscan binary; found ${JSON.stringify(listing)}`,
133
+ );
134
+ }
135
+
136
+ const tarResult = spawnSync('tar', ['-C', tmpRoot, '-xzf', archivePath], {
137
+ encoding: 'utf8',
138
+ env: publicVerifierSpawnEnv(),
139
+ timeout: 15_000,
140
+ });
141
+ if (tarResult.status !== 0 || tarResult.error) {
142
+ fail(`failed to extract current-platform CLI artifact ${archiveName}: ${describeSpawn(tarResult)}`);
143
+ }
144
+ const binaryPath = path.join(tmpRoot, PUBLIC_BINARY_NAME);
145
+ await assertFile(
146
+ binaryPath,
147
+ 'current-platform bundled binary',
148
+ 'Archive must contain a root starkscan binary.',
149
+ );
150
+ const helpResult = spawnSync(binaryPath, ['--help'], {
151
+ encoding: 'utf8',
152
+ env: publicVerifierSpawnEnv(),
153
+ timeout: 15_000,
154
+ });
155
+ if (helpResult.status !== 0 || helpResult.error) {
156
+ fail(
157
+ `current-platform bundled binary failed --help: ${describeSpawn(helpResult)}`,
158
+ );
159
+ }
160
+ const output = `${helpResult.stdout}\n${helpResult.stderr}`;
161
+ if (!output.includes(HOSTED_DEFAULT_BASE_URL)) {
162
+ fail(`current-platform bundled binary help does not advertise hosted default ${HOSTED_DEFAULT_BASE_URL}`);
163
+ }
164
+ for (const forbidden of PUBLIC_HELP_FORBIDDEN_STRINGS) {
165
+ if (output.includes(forbidden)) {
166
+ fail(`current-platform bundled binary help leaks forbidden public contract string: ${forbidden}`);
167
+ }
168
+ }
169
+ } finally {
170
+ await rm(tmpRoot, { recursive: true, force: true });
171
+ }
172
+ }
173
+
53
174
  async function main() {
54
175
  const packageJson = JSON.parse(await readFile(PACKAGE_JSON_PATH, 'utf8'));
55
176
  if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
@@ -114,6 +235,8 @@ async function main() {
114
235
  fail(`${archiveName} bytes do not match manifest sha256`);
115
236
  }
116
237
  }
238
+
239
+ await verifyCurrentPlatformHelpContract(releaseRoot, manifest);
117
240
  }
118
241
 
119
242
  main().catch((error) => {
@@ -1 +0,0 @@
1
- a7b841e723af2c89269ca28b461ca6ef94b3cda329a38076356837225e649496 starkscan-darwin-aarch64.tar.gz
@@ -1 +0,0 @@
1
- c99ddc5b28f6dcfab8523a6c13ab629fbc38a772c001bb75320490cb83fef382 starkscan-darwin-x86_64.tar.gz
@@ -1 +0,0 @@
1
- b41ed5a797590eab738a63999b64244e18a546d3ffb4b7659e3e67f81f894597 starkscan-linux-aarch64.tar.gz
@@ -1 +0,0 @@
1
- 604970a343d948d426f5482630ac36cd42eb56989f1b3c6eae9113e0bc1584de starkscan-linux-x86_64.tar.gz