@tealstreet/cli 0.9.3
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 +271 -0
- package/bin/tealstreet.cjs +19 -0
- package/package.json +67 -0
- package/scripts/install-binary.cjs +276 -0
package/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# Tealstreet CLI
|
|
2
|
+
|
|
3
|
+
Trade crypto from your terminal. Built on **Bun**, compiled to standalone
|
|
4
|
+
cross-platform binaries (darwin-arm64/x64, linux-x64/arm64).
|
|
5
|
+
|
|
6
|
+
> **Looking for usage docs?** Commands, account setup, sizing/price grammar,
|
|
7
|
+
> workflow operators, and recipes live at **<https://docs.tealstreet.io/cli/>**
|
|
8
|
+
> (source in [`apps/documentation/cli/`](../documentation/cli/)). **This README
|
|
9
|
+
> is for working on the CLI itself** — see that site for anything user-facing.
|
|
10
|
+
|
|
11
|
+
While the REPL is running it also acts as a loopback side-car for the web app
|
|
12
|
+
on the same machine — see
|
|
13
|
+
[`operations/listener.md`](../documentation/cli/operations/listener.md) for the
|
|
14
|
+
listener wire protocol, auth, and kill switches.
|
|
15
|
+
|
|
16
|
+
## Runtime
|
|
17
|
+
|
|
18
|
+
**Bun**, not Node.js (`bun-types` in tsconfig). Binaries are produced with
|
|
19
|
+
`bun build --compile`. Exchange integration delegates to
|
|
20
|
+
[`@tealstreet/safe-cex`](../../packages/safe-cex); shared command logic lives in
|
|
21
|
+
[`@tealstreet/cli-core`](../../packages/cli-core).
|
|
22
|
+
|
|
23
|
+
## Develop locally
|
|
24
|
+
|
|
25
|
+
Prerequisite: [Bun](https://bun.sh/) — the CLI's scripts invoke `bun`
|
|
26
|
+
directly. Install workspace deps from the **monorepo root** first (this is a
|
|
27
|
+
yarn workspace; `@tealstreet/*` deps resolve from there).
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
yarn install # from the monorepo root
|
|
31
|
+
|
|
32
|
+
cd apps/cli
|
|
33
|
+
yarn dev # run the REPL from source (= bun run src/index.ts)
|
|
34
|
+
yarn typecheck # tsc --noEmit
|
|
35
|
+
yarn test # vitest
|
|
36
|
+
yarn logs # tail ~/.tealstreet/cli.log
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Build a local binary
|
|
40
|
+
|
|
41
|
+
`yarn build` compiles standalone binaries for all four platforms (via
|
|
42
|
+
`bun build --compile`) into `dist/tealstreet-<platform>`. To build and run the
|
|
43
|
+
one for your machine:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
yarn build
|
|
47
|
+
./dist/tealstreet-darwin-arm64 # or -darwin-x64 / -linux-x64 / -linux-arm64
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### npm package
|
|
51
|
+
|
|
52
|
+
The public `@tealstreet/cli` package is a thin installer/launcher. It does not
|
|
53
|
+
ship CLI source; it downloads the matching binary from
|
|
54
|
+
`https://github.com/Tealstreet/cli/releases` on install or first run.
|
|
55
|
+
|
|
56
|
+
Config and runtime state live under `~/.tealstreet/` — see
|
|
57
|
+
[`operations/config-files.md`](../documentation/cli/operations/config-files.md).
|
|
58
|
+
|
|
59
|
+
## Architecture
|
|
60
|
+
|
|
61
|
+
[`CLAUDE.md`](./CLAUDE.md) has the full source map, the listener subsystem, the
|
|
62
|
+
remote-signing model, the logging taxonomy, and the script-types pipeline.
|
|
63
|
+
High-level entry points:
|
|
64
|
+
|
|
65
|
+
- `src/index.ts` — Commander.js entry
|
|
66
|
+
- `src/repl.ts` — interactive REPL (readline)
|
|
67
|
+
- `src/listener/` — loopback `127.0.0.1:53219` WS + HTTP side-car for the web app
|
|
68
|
+
- `src/commands/` — command implementations (shared logic in `@tealstreet/cli-core`)
|
|
69
|
+
- `src/config.ts` — `~/.tealstreet/config.json` (accounts, listener kill-switches, footer layout)
|
|
70
|
+
- `src/logger.ts` + `src/log-classify.ts` — console interception, severity taxonomy, operational-noise routing
|
|
71
|
+
- `src/scroll-region.ts` + `src/status-line.ts` + `src/footer.ts` + `src/footer-segments.ts` — the pinned output surfaces (top alert bar + bottom status footer over one shared ANSI scroll region)
|
|
72
|
+
- `src/audit-log.ts` — durable append-only write-op trail + command tracing (`audit` command)
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
### Watch Mode Pattern (Terminal Takeover)
|
|
77
|
+
|
|
78
|
+
Watch modes (wa, wp, wo, wm, watch chart, watch chase) need to "take over" the terminal to:
|
|
79
|
+
|
|
80
|
+
1. Display live-updating content
|
|
81
|
+
2. Capture single keystrokes to exit (Escape or Ctrl+X in REPL mode, also Ctrl+C when running via CLI args)
|
|
82
|
+
3. Prevent the REPL prompt from showing
|
|
83
|
+
|
|
84
|
+
**Exit Keys** (centralized in `utils/exit-keys.ts`):
|
|
85
|
+
|
|
86
|
+
- **REPL mode**: Escape or Ctrl+X (Ctrl+C stays with readline for line cancel)
|
|
87
|
+
- **CLI args mode**: Ctrl+C, Escape, or Ctrl+X
|
|
88
|
+
|
|
89
|
+
**The Pattern** (see `startWatchAccount` in repl.ts):
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import { isReplExitKey, getReplExitMessage } from './utils/exit-keys';
|
|
93
|
+
|
|
94
|
+
function startWatchMode(rl: readline.Interface): void {
|
|
95
|
+
// 1. Set isMonitorMode flag - CRITICAL! This prevents REPL prompt from showing
|
|
96
|
+
isMonitorMode = true;
|
|
97
|
+
|
|
98
|
+
// 2. Pause readline to stop it from processing input
|
|
99
|
+
rl.pause();
|
|
100
|
+
|
|
101
|
+
// 3. Enter alternate screen buffer (optional - keeps scrollback clean)
|
|
102
|
+
process.stdout.write(ENTER_ALT_SCREEN);
|
|
103
|
+
|
|
104
|
+
// 4. Set up raw mode key handler for exit keys
|
|
105
|
+
const onRawKey = (data: Buffer) => {
|
|
106
|
+
const key = data[0];
|
|
107
|
+
if (isReplExitKey(key)) {
|
|
108
|
+
stopWatch();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const stopWatch = () => {
|
|
113
|
+
if (!isMonitorMode) return; // Prevent double-stop
|
|
114
|
+
isMonitorMode = false;
|
|
115
|
+
|
|
116
|
+
// Clean up: remove key handler, exit raw mode, exit alt screen
|
|
117
|
+
process.stdin.removeListener('data', onRawKey);
|
|
118
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
119
|
+
process.stdout.write(EXIT_ALT_SCREEN);
|
|
120
|
+
|
|
121
|
+
// CRITICAL: Recreate readline to restore terminal state
|
|
122
|
+
recreateReadline();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// 5. Store stop function for cleanup (used by readline close handler)
|
|
126
|
+
stopMonitorFn = stopWatch;
|
|
127
|
+
|
|
128
|
+
// 6. Enter raw mode and start listening
|
|
129
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
130
|
+
process.stdin.on('data', onRawKey);
|
|
131
|
+
|
|
132
|
+
// 7. Return immediately! The function does NOT block.
|
|
133
|
+
// Watch mode runs via setInterval
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Why isMonitorMode is CRITICAL**:
|
|
138
|
+
|
|
139
|
+
- The REPL's command handler always shows the prompt after a command completes
|
|
140
|
+
- `isMonitorMode = true` suppresses this behavior (checked before calling `rl.prompt()`)
|
|
141
|
+
- Without this flag, the REPL prompt appears immediately after the watch command "returns"
|
|
142
|
+
|
|
143
|
+
**Key Points**:
|
|
144
|
+
|
|
145
|
+
1. Watch functions return IMMEDIATELY - they don't await/block
|
|
146
|
+
2. `isMonitorMode = true` prevents REPL prompt from showing
|
|
147
|
+
3. Raw mode (`setRawMode(true)`) captures single keystrokes
|
|
148
|
+
4. Must call `recreateReadline()` on cleanup (raw mode corrupts readline state)
|
|
149
|
+
|
|
150
|
+
### Raw Mode and Readline Pattern
|
|
151
|
+
|
|
152
|
+
Watch modes use raw stdin mode (`setRawMode(true)`) to capture single keystrokes (Escape, Ctrl+X). This corrupts Node.js readline's internal state, causing issues like:
|
|
153
|
+
|
|
154
|
+
- Double-printed characters
|
|
155
|
+
- Arrow keys printing escape sequences (`[A`) instead of navigating history
|
|
156
|
+
- Random input clearing
|
|
157
|
+
|
|
158
|
+
**Solution**: After exiting raw mode, **recreate the readline interface entirely** instead of just resuming it. The `recreateReadline()` helper in `repl.ts` handles this:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
function recreateReadline(): void {
|
|
162
|
+
if (!rlInstance) return;
|
|
163
|
+
|
|
164
|
+
// Preserve history
|
|
165
|
+
const history = (rlInstance as readline.Interface & { history?: string[] }).history || [];
|
|
166
|
+
|
|
167
|
+
// Close old readline (with flag to prevent exit handler)
|
|
168
|
+
isRecreatingReadline = true;
|
|
169
|
+
rlInstance.close();
|
|
170
|
+
isRecreatingReadline = false;
|
|
171
|
+
|
|
172
|
+
// Create fresh readline
|
|
173
|
+
rlInstance = readline.createInterface({
|
|
174
|
+
input: process.stdin,
|
|
175
|
+
output: process.stdout,
|
|
176
|
+
prompt: getPrompt(),
|
|
177
|
+
history: [...history],
|
|
178
|
+
historySize: MAX_HISTORY_SIZE,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Re-setup event handlers...
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Important**: All watch mode stop functions must call `recreateReadline()` to restore proper terminal state.
|
|
186
|
+
|
|
187
|
+
### Console output, logging & status surfaces
|
|
188
|
+
|
|
189
|
+
`src/logger.ts` intercepts `console.*` and routes each line through, in order:
|
|
190
|
+
**operational-noise suppression** → **rate limit** → **readline-aware
|
|
191
|
+
printing**. The full taxonomy and the user-facing behaviour are documented in
|
|
192
|
+
[`CLAUDE.md`](./CLAUDE.md#logging) and the public
|
|
193
|
+
[Output reference](../documentation/cli/reference/output.md) — keep all three in
|
|
194
|
+
sync (see the doc-sync table at the top of `CLAUDE.md`). The essentials:
|
|
195
|
+
|
|
196
|
+
- **Operational noise → file only.** Lines matching `OPERATIONAL_PATTERNS` in
|
|
197
|
+
`src/log-classify.ts` (keepalive pings, reconnect chatter, balance dumps) are
|
|
198
|
+
kept out of the terminal — they still hit `~/.tealstreet/cli.log`. Add new
|
|
199
|
+
noisy prefixes there, not in `logger.ts`.
|
|
200
|
+
- **Everything else prints _above_ the prompt** via `writeAbovePrompt`
|
|
201
|
+
(readline-aware), so async output never corrupts what the user is typing.
|
|
202
|
+
`console.warn` / `console.error` (and any line `log-classify.ts` rates
|
|
203
|
+
`critical`) are additionally mirrored on the **top status bar**
|
|
204
|
+
(`src/status-line.ts`). ("critical" is a severity classification, not a
|
|
205
|
+
`console` method.)
|
|
206
|
+
- **`print()` / `originalConsoleLog()` bypass all of the above** — use them for
|
|
207
|
+
primary command output, prompts, progress, and anything the user explicitly
|
|
208
|
+
asked for.
|
|
209
|
+
- **Verbose transport diagnostics share `cli.log`.** When the CLI runs with
|
|
210
|
+
`DEBUG=1` or `DEBUG=true`, tunneled HoW/tradetunnel REST requests emit
|
|
211
|
+
sanitized `[TradeTunnel]` request/response summaries through the existing
|
|
212
|
+
`console.log` interception. The `[TradeTunnel]` prefix is classified as
|
|
213
|
+
operational, so those lines land in `~/.tealstreet/cli.log` and stay off the
|
|
214
|
+
terminal. This is off by default; it is not a separate network log file.
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { originalConsoleLog } from '../logger';
|
|
218
|
+
const print = originalConsoleLog; // never throttled / suppressed
|
|
219
|
+
print(chalk.green('✓ Operation complete'));
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Pinned surfaces** (one shared ANSI scroll region, `src/scroll-region.ts`):
|
|
223
|
+
the transient **top alert bar** (`status-line.ts`, warn/error/critical, dims)
|
|
224
|
+
and the always-on **bottom footer** (`footer.ts` + `footer-segments.ts`,
|
|
225
|
+
user-customizable segments). Both no-op on non-TTY.
|
|
226
|
+
|
|
227
|
+
**Command tracing**: write-ops can be recorded to a durable append-only
|
|
228
|
+
`~/.tealstreet/audit.log` with per-invocation `runId` + fill attribution
|
|
229
|
+
(`src/audit-log.ts`, `audit` command).
|
|
230
|
+
|
|
231
|
+
## Building & Releasing
|
|
232
|
+
|
|
233
|
+
### Prerequisites
|
|
234
|
+
|
|
235
|
+
- [Bun](https://bun.sh/) - `curl -fsSL https://bun.sh/install | bash`
|
|
236
|
+
|
|
237
|
+
### Build (local only)
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# Build binaries for all platforms (darwin-arm64, darwin-x64, linux-x64, linux-arm64)
|
|
241
|
+
bun run build
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Binaries are output to `dist/`.
|
|
245
|
+
|
|
246
|
+
### Release to GitHub
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
# Release with version bump
|
|
250
|
+
yarn release:patch # 0.1.0 -> 0.1.1
|
|
251
|
+
yarn release:minor # 0.1.0 -> 0.2.0
|
|
252
|
+
yarn release:major # 0.1.0 -> 1.0.0
|
|
253
|
+
|
|
254
|
+
# Release current package.json version
|
|
255
|
+
yarn release
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This will:
|
|
259
|
+
|
|
260
|
+
1. Bump version in package.json (if specified)
|
|
261
|
+
2. Commit the version bump
|
|
262
|
+
3. Push a private monorepo tag named `cli-vX.Y.Z`
|
|
263
|
+
4. Let GitHub Actions build binaries, publish public `Tealstreet/cli`
|
|
264
|
+
release `vX.Y.Z`, and update `Tealstreet/homebrew-tap`
|
|
265
|
+
|
|
266
|
+
The workflow uses repository secret `TEALSTREET_CLI_RELEASE_TOKEN` to write
|
|
267
|
+
to the public release repo and Homebrew tap.
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { spawnSync } = require('node:child_process');
|
|
5
|
+
const { ensureBinary } = require('../scripts/install-binary.cjs');
|
|
6
|
+
|
|
7
|
+
const binary = ensureBinary({ optional: false });
|
|
8
|
+
const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
|
|
9
|
+
|
|
10
|
+
if (result.error) {
|
|
11
|
+
console.error(result.error.message);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (result.signal) {
|
|
16
|
+
process.kill(process.pid, result.signal);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
process.exit(result.status ?? 0);
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tealstreet/cli",
|
|
3
|
+
"version": "0.9.3",
|
|
4
|
+
"description": "Tealstreet CLI - Trade crypto from your terminal",
|
|
5
|
+
"main": "bin/tealstreet.cjs",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"tealstreet": "bin/tealstreet.cjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"scripts/install-binary.cjs",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/Tealstreet/tealstreet-next.git",
|
|
18
|
+
"directory": "apps/cli"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"cli": "bun src/index.ts",
|
|
28
|
+
"dev": "bun run src/index.ts",
|
|
29
|
+
"build": "./scripts/build.sh",
|
|
30
|
+
"release": "./scripts/release.sh",
|
|
31
|
+
"release:patch": "./scripts/release.sh patch",
|
|
32
|
+
"release:minor": "./scripts/release.sh minor",
|
|
33
|
+
"release:major": "./scripts/release.sh major",
|
|
34
|
+
"lint": "eslint src",
|
|
35
|
+
"version:patch": "yarn version --patch --no-git-tag-version",
|
|
36
|
+
"version:minor": "yarn version --minor --no-git-tag-version",
|
|
37
|
+
"version:major": "yarn version --major --no-git-tag-version",
|
|
38
|
+
"typecheck": "tsc --noEmit --skipLibCheck",
|
|
39
|
+
"logs": "tail -f ~/.tealstreet/cli.log",
|
|
40
|
+
"test": "vitest",
|
|
41
|
+
"test-ci": "vitest run",
|
|
42
|
+
"postinstall": "node scripts/install-binary.cjs --optional"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@tealstreet/cli-core": "*",
|
|
46
|
+
"@tealstreet/safe-cex": "*",
|
|
47
|
+
"@tealstreet/script-api-cli": "*",
|
|
48
|
+
"@tealstreet/script-api-core": "*",
|
|
49
|
+
"@tealstreet/script-api-web": "*",
|
|
50
|
+
"@tealstreet/utils": "*",
|
|
51
|
+
"chalk": "^5.3.0",
|
|
52
|
+
"commander": "^15.0.0",
|
|
53
|
+
"fast-json-patch": "^3.1.1",
|
|
54
|
+
"open": "^11.0.0",
|
|
55
|
+
"ora": "^9.4.0",
|
|
56
|
+
"tcp-websocket": "^1.0.0",
|
|
57
|
+
"ws": "^8.21.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "^20.0.0",
|
|
61
|
+
"@types/ws": "^8.18.1",
|
|
62
|
+
"bun-types": "^1.3.14",
|
|
63
|
+
"typescript": "^6.0.3",
|
|
64
|
+
"vite-tsconfig-paths": "^5.1.4",
|
|
65
|
+
"vitest": "^4.1.7"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const https = require('node:https');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const packageJson = require('../package.json');
|
|
11
|
+
|
|
12
|
+
const MAX_REDIRECTS = 10;
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
function resolvePlatform() {
|
|
16
|
+
const platformMap = {
|
|
17
|
+
darwin: 'darwin',
|
|
18
|
+
linux: 'linux',
|
|
19
|
+
};
|
|
20
|
+
const archMap = {
|
|
21
|
+
arm64: 'arm64',
|
|
22
|
+
x64: 'x64',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const platform = platformMap[process.platform];
|
|
26
|
+
const arch = archMap[process.arch];
|
|
27
|
+
|
|
28
|
+
if (!platform || !arch) {
|
|
29
|
+
throw new Error(`Unsupported platform: ${process.platform}-${process.arch}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return `${platform}-${arch}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getCacheDir() {
|
|
36
|
+
const root =
|
|
37
|
+
process.env.TEALSTREET_CLI_CACHE_DIR ||
|
|
38
|
+
process.env.XDG_CACHE_HOME ||
|
|
39
|
+
path.join(os.homedir(), '.cache');
|
|
40
|
+
return path.join(root, 'tealstreet-cli', packageJson.version);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getBinaryPath() {
|
|
44
|
+
const target = resolvePlatform();
|
|
45
|
+
return path.join(getCacheDir(), `tealstreet-${target}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sha256File(file) {
|
|
49
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateDownloadInputs(url, destination) {
|
|
53
|
+
if (!url || !destination) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'--download-only requires TEALSTREET_CLI_DOWNLOAD_URL and TEALSTREET_CLI_DOWNLOAD_DESTINATION',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const parsedUrl = new URL(url);
|
|
60
|
+
if (parsedUrl.protocol !== 'https:') {
|
|
61
|
+
throw new Error(`Unsupported download URL protocol: ${parsedUrl.protocol}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!path.isAbsolute(destination)) {
|
|
65
|
+
throw new Error('TEALSTREET_CLI_DOWNLOAD_DESTINATION must be an absolute path');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function requestUrl(url, onResponse, redirectDepth = 0) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
if (redirectDepth > MAX_REDIRECTS) {
|
|
72
|
+
reject(new Error(`Too many redirects for ${url}`));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const req = https.get(url, (response) => {
|
|
77
|
+
if (
|
|
78
|
+
response.statusCode >= 300 &&
|
|
79
|
+
response.statusCode < 400 &&
|
|
80
|
+
response.headers.location
|
|
81
|
+
) {
|
|
82
|
+
const nextUrl = new URL(response.headers.location, url).toString();
|
|
83
|
+
response.resume();
|
|
84
|
+
requestUrl(nextUrl, onResponse, redirectDepth + 1).then(resolve, reject);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
onResponse(response, resolve, reject);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
reject(error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
96
|
+
req.destroy(new Error(`Request timeout after ${REQUEST_TIMEOUT_MS}ms: ${url}`));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
req.on('error', (error) => {
|
|
100
|
+
reject(error);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fetchText(url) {
|
|
106
|
+
return requestUrl(url, (response, resolve, reject) => {
|
|
107
|
+
if (response.statusCode !== 200) {
|
|
108
|
+
response.resume();
|
|
109
|
+
reject(new Error(`Download failed with HTTP ${response.statusCode}: ${url}`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
response.setEncoding('utf8');
|
|
114
|
+
let body = '';
|
|
115
|
+
response.on('data', (chunk) => {
|
|
116
|
+
body += chunk;
|
|
117
|
+
});
|
|
118
|
+
response.on('end', () => resolve(body));
|
|
119
|
+
response.on('error', reject);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function download(url, destination, { expectedSha256 } = {}) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
126
|
+
const temp = `${destination}.tmp-${process.pid}`;
|
|
127
|
+
let settled = false;
|
|
128
|
+
|
|
129
|
+
const cleanup = () => fs.rmSync(temp, { force: true });
|
|
130
|
+
const fail = (error) => {
|
|
131
|
+
if (settled) return;
|
|
132
|
+
settled = true;
|
|
133
|
+
cleanup();
|
|
134
|
+
reject(error);
|
|
135
|
+
};
|
|
136
|
+
const succeed = () => {
|
|
137
|
+
if (settled) return;
|
|
138
|
+
settled = true;
|
|
139
|
+
resolve();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
requestUrl(url, (response, requestResolve, requestReject) => {
|
|
143
|
+
if (response.statusCode !== 200) {
|
|
144
|
+
response.resume();
|
|
145
|
+
requestReject(new Error(`Download failed with HTTP ${response.statusCode}: ${url}`));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const file = fs.createWriteStream(temp, { mode: 0o755 });
|
|
150
|
+
response.pipe(file);
|
|
151
|
+
|
|
152
|
+
response.on('error', (error) => {
|
|
153
|
+
file.destroy(error);
|
|
154
|
+
});
|
|
155
|
+
file.on('error', requestReject);
|
|
156
|
+
file.on('finish', () => {
|
|
157
|
+
file.close((error) => {
|
|
158
|
+
if (error) {
|
|
159
|
+
requestReject(error);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
if (expectedSha256 && sha256File(temp) !== expectedSha256) {
|
|
165
|
+
requestReject(new Error(`Checksum mismatch for ${url}`));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fs.chmodSync(temp, 0o755);
|
|
170
|
+
fs.renameSync(temp, destination);
|
|
171
|
+
requestResolve();
|
|
172
|
+
} catch (renameError) {
|
|
173
|
+
requestReject(renameError);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}).then(succeed, fail);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function fetchExpectedSha256(target) {
|
|
182
|
+
const sumsUrl = `https://github.com/Tealstreet/cli/releases/download/v${packageJson.version}/SHA256SUMS`;
|
|
183
|
+
const sums = await fetchText(sumsUrl);
|
|
184
|
+
for (const line of sums.split(/\r?\n/)) {
|
|
185
|
+
const [hash, file] = line.trim().split(/\s+/);
|
|
186
|
+
if (file === `tealstreet-${target}` && /^[a-f0-9]{64}$/i.test(hash)) {
|
|
187
|
+
return hash;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`Missing checksum for tealstreet-${target}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ensureBinary({ optional = false } = {}) {
|
|
194
|
+
const target = resolvePlatform();
|
|
195
|
+
const binary = getBinaryPath();
|
|
196
|
+
|
|
197
|
+
if (fs.existsSync(binary)) {
|
|
198
|
+
return binary;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const url = `https://github.com/Tealstreet/cli/releases/download/v${packageJson.version}/tealstreet-${target}`;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const childProcess = require('node:child_process');
|
|
205
|
+
const expectedSha256 = childProcess
|
|
206
|
+
.execFileSync(process.execPath, [__filename, '--checksum-only'], {
|
|
207
|
+
encoding: 'utf8',
|
|
208
|
+
env: {
|
|
209
|
+
...process.env,
|
|
210
|
+
TEALSTREET_CLI_CHECKSUM_TARGET: target,
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
.trim();
|
|
214
|
+
childProcess.execFileSync(process.execPath, [__filename, '--download-only'], {
|
|
215
|
+
stdio: optional ? 'ignore' : 'inherit',
|
|
216
|
+
env: {
|
|
217
|
+
...process.env,
|
|
218
|
+
TEALSTREET_CLI_DOWNLOAD_URL: url,
|
|
219
|
+
TEALSTREET_CLI_DOWNLOAD_DESTINATION: binary,
|
|
220
|
+
TEALSTREET_CLI_DOWNLOAD_SHA256: expectedSha256,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
return binary;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (optional) {
|
|
226
|
+
console.warn(`tealstreet: skipped binary preinstall (${error.message})`);
|
|
227
|
+
return fs.existsSync(binary) ? binary : null;
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function main() {
|
|
234
|
+
const optional = process.argv.includes('--optional');
|
|
235
|
+
const downloadOnly = process.argv.includes('--download-only');
|
|
236
|
+
const checksumOnly = process.argv.includes('--checksum-only');
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
if (checksumOnly) {
|
|
240
|
+
const target = process.env.TEALSTREET_CLI_CHECKSUM_TARGET;
|
|
241
|
+
if (!target) {
|
|
242
|
+
throw new Error('--checksum-only requires TEALSTREET_CLI_CHECKSUM_TARGET');
|
|
243
|
+
}
|
|
244
|
+
process.stdout.write(await fetchExpectedSha256(target));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (downloadOnly) {
|
|
249
|
+
const url = process.env.TEALSTREET_CLI_DOWNLOAD_URL;
|
|
250
|
+
const destination = process.env.TEALSTREET_CLI_DOWNLOAD_DESTINATION;
|
|
251
|
+
const expectedSha256 = process.env.TEALSTREET_CLI_DOWNLOAD_SHA256;
|
|
252
|
+
validateDownloadInputs(url, destination);
|
|
253
|
+
await download(url, destination, { expectedSha256 });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
ensureBinary({ optional });
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (optional) {
|
|
260
|
+
console.warn(`tealstreet: skipped binary preinstall (${error.message})`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
console.error(error.message);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (require.main === module) {
|
|
269
|
+
main();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = {
|
|
273
|
+
ensureBinary,
|
|
274
|
+
getBinaryPath,
|
|
275
|
+
resolvePlatform,
|
|
276
|
+
};
|