@starkscan/cli 0.1.0-alpha.2 → 0.1.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 +83 -43
- package/artifacts/{v0.1.0-alpha.2 → v0.1.0}/starkscan-cli-manifest.json +5 -5
- package/artifacts/{v0.1.0-alpha.2/starkscan-darwin-x86_64.tar.gz → v0.1.0/starkscan-darwin-aarch64.tar.gz} +0 -0
- package/artifacts/v0.1.0/starkscan-darwin-aarch64.tar.gz.sha256 +1 -0
- package/artifacts/v0.1.0/starkscan-darwin-x86_64.tar.gz +0 -0
- package/artifacts/v0.1.0/starkscan-darwin-x86_64.tar.gz.sha256 +1 -0
- package/artifacts/{v0.1.0-alpha.2/starkscan-linux-x86_64.tar.gz → v0.1.0/starkscan-linux-aarch64.tar.gz} +0 -0
- package/artifacts/v0.1.0/starkscan-linux-aarch64.tar.gz.sha256 +1 -0
- package/artifacts/v0.1.0/starkscan-linux-x86_64.tar.gz +0 -0
- package/artifacts/v0.1.0/starkscan-linux-x86_64.tar.gz.sha256 +1 -0
- package/bin/starkscan.js +20 -2
- package/package.json +20 -7
- package/scripts/installer.mjs +381 -29
- package/scripts/verify-bundled-artifacts.mjs +124 -1
- package/artifacts/v0.1.0-alpha.2/starkscan-darwin-aarch64.tar.gz +0 -0
- package/artifacts/v0.1.0-alpha.2/starkscan-darwin-aarch64.tar.gz.sha256 +0 -1
- package/artifacts/v0.1.0-alpha.2/starkscan-darwin-x86_64.tar.gz.sha256 +0 -1
- package/artifacts/v0.1.0-alpha.2/starkscan-linux-aarch64.tar.gz +0 -0
- package/artifacts/v0.1.0-alpha.2/starkscan-linux-aarch64.tar.gz.sha256 +0 -1
- package/artifacts/v0.1.0-alpha.2/starkscan-linux-x86_64.tar.gz.sha256 +0 -1
package/README.md
CHANGED
|
@@ -1,73 +1,113 @@
|
|
|
1
1
|
# @starkscan/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Command-line Starkscan client for status checks, explorer reads, local exports,
|
|
4
|
+
and agent setup.
|
|
5
|
+
|
|
6
|
+
Status: public `0.1.0` stable release. The npm `latest` tag points to the real
|
|
7
|
+
CLI package. Use the untagged install for normal setup and pin `0.1.0` for
|
|
8
|
+
unattended agents or production services that need reproducible installs.
|
|
4
9
|
|
|
5
10
|
## Install
|
|
6
11
|
|
|
7
12
|
```bash
|
|
8
|
-
npm install -g @starkscan/cli
|
|
13
|
+
npm install -g @starkscan/cli
|
|
9
14
|
starkscan doctor
|
|
10
15
|
```
|
|
11
16
|
|
|
12
|
-
One-off agent
|
|
17
|
+
One-off agent setup without a global install:
|
|
13
18
|
|
|
14
19
|
```bash
|
|
15
|
-
npx @starkscan/cli
|
|
20
|
+
npx -y @starkscan/cli init --agent --output-format json
|
|
16
21
|
```
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
of floating on `@alpha`.
|
|
23
|
+
Exact pin for unattended services:
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
```bash
|
|
26
|
+
npx -y @starkscan/cli@0.1.0 init --agent --output-format json
|
|
27
|
+
```
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
The `@beta` tag remains a prerelease channel only. Do not use it for unattended
|
|
30
|
+
production services unless a maintainer explicitly asks you to test a
|
|
31
|
+
prerelease.
|
|
32
|
+
|
|
33
|
+
## First commands
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export STARKSCAN_API_KEY="<store this in your shell or agent secret store>"
|
|
37
|
+
export STARKSCAN_CHAIN="SN_MAIN"
|
|
38
|
+
# Optional: only set this for preview or self-hosted hosts.
|
|
39
|
+
# export STARKSCAN_BASE_URL="https://preview.example.com/api"
|
|
40
|
+
|
|
41
|
+
starkscan status
|
|
42
|
+
starkscan block 8279910
|
|
43
|
+
starkscan tx 0x1234abcd
|
|
44
|
+
starkscan search 0x1234abcd
|
|
45
|
+
```
|
|
27
46
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
source repository is private, so public package trust links intentionally point
|
|
33
|
-
to Starkscan docs instead of GitHub.
|
|
47
|
+
The CLI defaults to `https://api.starkscan.co`, sends `X-Starkscan-Api-Key`,
|
|
48
|
+
and uses the same REST API contract as the SDK. MCP uses a separate
|
|
49
|
+
transport: `POST https://api.starkscan.co/mcp` on the API domain, or
|
|
50
|
+
`POST {appBaseUrl}/api/mcp` on app-origin deployments.
|
|
34
51
|
|
|
35
|
-
|
|
52
|
+
## Why use it
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
- No Rust toolchain or private repository access required.
|
|
55
|
+
- Prebuilt native `starkscan` binaries are bundled in the npm package.
|
|
56
|
+
- Bundled or downloaded native archives are verified against manifest checksums
|
|
57
|
+
before execution.
|
|
58
|
+
- `--output-format json` keeps stdout parseable for agents and CI.
|
|
59
|
+
- Stable exit classes make automation predictable: success, usage, auth,
|
|
60
|
+
rate-limit, timeout, and not-found errors are distinct.
|
|
61
|
+
- MCP helpers print ready-to-paste Codex, Claude Code, Cursor, Claude Desktop,
|
|
62
|
+
and Cline configs with environment placeholders, not secret values.
|
|
63
|
+
|
|
64
|
+
## Agent setup
|
|
40
65
|
|
|
41
66
|
```bash
|
|
42
|
-
npx -y @starkscan/cli@
|
|
43
|
-
npx -y @starkscan/cli@
|
|
67
|
+
npx -y @starkscan/cli@0.1.0 init --agent --output-format json
|
|
68
|
+
npx -y @starkscan/cli@0.1.0 mcp print-config --transport remote
|
|
69
|
+
npx -y @starkscan/cli@0.1.0 mcp start --transport remote
|
|
44
70
|
```
|
|
45
71
|
|
|
46
|
-
|
|
72
|
+
`print-config` emits machine-readable snippets. Keep `STARKSCAN_API_KEY` in the
|
|
73
|
+
host shell, CI secret store, or MCP client secret store.
|
|
74
|
+
|
|
75
|
+
## Launcher inspection
|
|
76
|
+
|
|
77
|
+
Agents can inspect the launcher decision without executing the native binary:
|
|
47
78
|
|
|
48
79
|
```bash
|
|
49
|
-
|
|
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
|
-
export STARKSCAN_API_KEY="mzk_test_your_key_here"
|
|
53
|
-
export STARKSCAN_CHAIN="SN_MAIN"
|
|
80
|
+
npx -y @starkscan/cli@0.1.0 --launcher-json
|
|
54
81
|
```
|
|
55
82
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
The JSON includes the package version, resolved binary path, release tag,
|
|
84
|
+
source (`bundled`, `download`, `bin-path`, or `unknown` for legacy caches),
|
|
85
|
+
cache-hit status, and verification status.
|
|
59
86
|
|
|
60
|
-
##
|
|
87
|
+
## Trust and safety
|
|
61
88
|
|
|
62
|
-
|
|
89
|
+
- npm package: <https://www.npmjs.com/package/@starkscan/cli>
|
|
90
|
+
- CLI docs: <https://starkscan.co/docs/ai/agent-cli>
|
|
91
|
+
- API key setup: <https://starkscan.co/api-key>
|
|
92
|
+
- Package trust: <https://starkscan.co/docs/build/package-trust>
|
|
93
|
+
- Machine-readable launch matrix: <https://starkscan.co/public-client-surface-matrix.json>
|
|
94
|
+
- Socket signal: <https://socket.dev/npm/package/@starkscan/cli>
|
|
95
|
+
|
|
96
|
+
Socket is an external package-risk signal, not a Starkscan security certificate.
|
|
97
|
+
The public trust source is Starkscan docs because the canonical engineering
|
|
98
|
+
repository is private. Package promotion also requires checked release scripts,
|
|
99
|
+
native artifact checksum verification, npm Trusted Publishing/OIDC for CI
|
|
100
|
+
publishes, and live smoke proof against the hosted API.
|
|
63
101
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
- `STARKSCAN_CLI_PLATFORM_OVERRIDE`: force a specific target platform for cross-platform testing
|
|
71
|
-
- `STARKSCAN_CLI_DOWNLOAD_TIMEOUT_MS`: download inactivity timeout in milliseconds, default `120000`
|
|
102
|
+
The CLI forwards public API keys only to Starkscan HTTPS hosts or
|
|
103
|
+
localhost/loopback fixtures. When intentionally testing a private API host, pass
|
|
104
|
+
`--allow-untrusted-base-url` explicitly so keys are not replayed to an
|
|
105
|
+
unexpected endpoint by default.
|
|
106
|
+
|
|
107
|
+
## Release binding
|
|
72
108
|
|
|
73
|
-
The package
|
|
109
|
+
The package resolves bundled artifacts under `artifacts/v<package-version>` when
|
|
110
|
+
published with native binaries included. Maintainer overrides such as
|
|
111
|
+
`STARKSCAN_CLI_RELEASE_TAG`, `STARKSCAN_CLI_RELEASE_BASE_URL`,
|
|
112
|
+
`STARKSCAN_CLI_BUNDLED_ARTIFACT_DIR`, and `STARKSCAN_CLI_BIN_PATH` exist for
|
|
113
|
+
tests and emergency rollback; public clients should not need them.
|
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-06-
|
|
3
|
+
"generatedAt": "2026-06-29T13:07:15Z",
|
|
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": "
|
|
10
|
+
"sha256": "f09d9f6e76be371e0f87da9d1540a77607c37cabb94c69c59959c02cbf523a14"
|
|
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": "
|
|
17
|
+
"sha256": "46319387c9b77503a79dc617fc28aa6b35ba34edda3a6997ce70701591f51885"
|
|
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": "
|
|
24
|
+
"sha256": "767bd29fe5562a3ab114f098eecda36d05e816f48e6f8e57df0f66970897887e"
|
|
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": "
|
|
31
|
+
"sha256": "2f51b30701dce6502cf4cf070adfcb839c74720015524197de679b65d6d927f5"
|
|
32
32
|
}
|
|
33
33
|
]
|
|
34
34
|
}
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
f09d9f6e76be371e0f87da9d1540a77607c37cabb94c69c59959c02cbf523a14 starkscan-darwin-aarch64.tar.gz
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
46319387c9b77503a79dc617fc28aa6b35ba34edda3a6997ce70701591f51885 starkscan-darwin-x86_64.tar.gz
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
767bd29fe5562a3ab114f098eecda36d05e816f48e6f8e57df0f66970897887e starkscan-linux-aarch64.tar.gz
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2f51b30701dce6502cf4cf070adfcb839c74720015524197de679b65d6d927f5 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,
|
|
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
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "0.1.0",
|
|
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
|
-
"
|
|
37
|
-
"
|
|
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"
|
package/scripts/installer.mjs
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
)
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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) => {
|
|
Binary file
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
a7b841e723af2c89269ca28b461ca6ef94b3cda329a38076356837225e649496 starkscan-darwin-aarch64.tar.gz
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
c99ddc5b28f6dcfab8523a6c13ab629fbc38a772c001bb75320490cb83fef382 starkscan-darwin-x86_64.tar.gz
|
|
Binary file
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
b41ed5a797590eab738a63999b64244e18a546d3ffb4b7659e3e67f81f894597 starkscan-linux-aarch64.tar.gz
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
604970a343d948d426f5482630ac36cd42eb56989f1b3c6eae9113e0bc1584de starkscan-linux-x86_64.tar.gz
|