@hyclaw/cli 3.0.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/LICENSE +401 -0
- package/README.md +373 -0
- package/backend/.gitkeep +0 -0
- package/dist/__tests__/store.test.d.ts +1 -0
- package/dist/__tests__/store.test.js +43 -0
- package/dist/__tests__/theme.test.d.ts +1 -0
- package/dist/__tests__/theme.test.js +40 -0
- package/dist/__tests__/tokenEstimate.test.d.ts +1 -0
- package/dist/__tests__/tokenEstimate.test.js +48 -0
- package/dist/cli.js +83 -0
- package/dist/commands/index.d.ts +18 -0
- package/dist/commands/index.js +99 -0
- package/dist/components/ApprovalModal.d.ts +8 -0
- package/dist/components/ApprovalModal.js +47 -0
- package/dist/components/ChatArea.d.ts +10 -0
- package/dist/components/ChatArea.js +49 -0
- package/dist/components/CommandPalette.d.ts +6 -0
- package/dist/components/CommandPalette.js +72 -0
- package/dist/components/StatusLine.d.ts +1 -0
- package/dist/components/StatusLine.js +25 -0
- package/dist/components/TextInput.d.ts +10 -0
- package/dist/components/TextInput.js +118 -0
- package/dist/hooks/useAppState.d.ts +18 -0
- package/dist/hooks/useAppState.js +42 -0
- package/dist/hooks/useWebSocket.d.ts +3 -0
- package/dist/hooks/useWebSocket.js +8 -0
- package/package.json +33 -0
- package/scripts/download-jre.js +201 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* CommandPalette — / filterable popup.
|
|
4
|
+
* Character input is handled by TextInput; this only handles navigation.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, useMemo, useEffect } from 'react';
|
|
7
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
8
|
+
import { ALL_COMMANDS } from '../commands/index.js';
|
|
9
|
+
export function CommandPalette({ filter, onSelect }) {
|
|
10
|
+
const [selected, setSelected] = useState(0);
|
|
11
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
12
|
+
const { stdout } = useStdout();
|
|
13
|
+
const terminalRows = stdout?.rows || 24;
|
|
14
|
+
const visible = useMemo(() => {
|
|
15
|
+
const f = filter.replace(/^\//, '').toLowerCase();
|
|
16
|
+
if (!f)
|
|
17
|
+
return ALL_COMMANDS;
|
|
18
|
+
return ALL_COMMANDS.filter(c => c.cmd.toLowerCase().includes(f) || c.desc.includes(f));
|
|
19
|
+
}, [filter]);
|
|
20
|
+
useEffect(() => { setSelected(0); setScrollOffset(0); }, [filter]);
|
|
21
|
+
const maxShow = Math.max(5, terminalRows - 13);
|
|
22
|
+
// Keep selected row in view
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setScrollOffset(prev => {
|
|
25
|
+
if (selected < prev)
|
|
26
|
+
return selected;
|
|
27
|
+
if (selected >= prev + maxShow)
|
|
28
|
+
return selected - maxShow + 1;
|
|
29
|
+
return prev;
|
|
30
|
+
});
|
|
31
|
+
}, [selected, maxShow]);
|
|
32
|
+
const sliced = visible.slice(scrollOffset, scrollOffset + maxShow);
|
|
33
|
+
useInput((_input, key) => {
|
|
34
|
+
if (key.escape) {
|
|
35
|
+
onSelect(null);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (key.downArrow) {
|
|
39
|
+
setSelected(prev => Math.min(prev + 1, visible.length - 1));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (key.upArrow) {
|
|
43
|
+
setSelected(prev => Math.max(prev - 1, 0));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (key.pageDown) {
|
|
47
|
+
setSelected(prev => Math.min(prev + maxShow, visible.length - 1));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (key.pageUp) {
|
|
51
|
+
setSelected(prev => Math.max(prev - maxShow, 0));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (key.home) {
|
|
55
|
+
setSelected(0);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (key.end) {
|
|
59
|
+
setSelected(visible.length - 1);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (key.return) {
|
|
63
|
+
if (visible.length > 0 && selected >= 0 && selected < visible.length) {
|
|
64
|
+
onSelect(visible[selected].cmd);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, width: 52, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "\u547D\u4EE4\u5217\u8868" }), _jsx(Text, { dimColor: true, children: " \u2191\u2193\u9009\u62E9 / PgUp/PgDn\u7FFB\u9875 / \u56DE\u8F66\u786E\u8BA4 / Esc\u53D6\u6D88" })] }), sliced.map((cmd, i) => {
|
|
69
|
+
const idx = scrollOffset + i;
|
|
70
|
+
return (_jsxs(Box, { paddingLeft: 1, children: [_jsx(Text, { color: idx === selected ? 'cyan' : undefined, bold: idx === selected, children: idx === selected ? '> ' : ' ' }), _jsx(Text, { color: "green", children: cmd.cmd }), _jsxs(Text, { dimColor: true, children: [" ", cmd.desc] }), _jsxs(Text, { color: cmd.via === 'ws' ? 'yellow' : 'blue', dimColor: idx !== selected, children: ["(", cmd.via === 'ws' ? '后端' : '本地', ")"] })] }, cmd.cmd));
|
|
71
|
+
}), visible.length > maxShow && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [" ", scrollOffset + 1, "-", Math.min(scrollOffset + maxShow, visible.length), " / ", visible.length] }) }))] }));
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function StatusLine(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useAppState } from '../hooks/useAppState.js';
|
|
4
|
+
function formatTokens(n) {
|
|
5
|
+
if (n >= 1_000_000)
|
|
6
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
7
|
+
if (n >= 1_000)
|
|
8
|
+
return `${Math.round(n / 1_000)}K`;
|
|
9
|
+
return String(n);
|
|
10
|
+
}
|
|
11
|
+
export function StatusLine() {
|
|
12
|
+
const state = useAppState();
|
|
13
|
+
const { usage, modelName, planMode, autoMode, connected, statusText, messages } = state;
|
|
14
|
+
const msgCount = messages.length;
|
|
15
|
+
const pct = Math.min(100, Math.round(usage.usageRatio * 100));
|
|
16
|
+
const filled = Math.round(pct / 10);
|
|
17
|
+
const bar = '='.repeat(filled) + '-'.repeat(10 - filled);
|
|
18
|
+
const model = modelName || (connected ? 'ready' : 'connecting...');
|
|
19
|
+
const modeLabel = planMode ? ' Plan ' : ' Act ';
|
|
20
|
+
const modeColor = planMode ? 'cyan' : 'green';
|
|
21
|
+
const connIcon = connected ? '●' : '○';
|
|
22
|
+
const connColor = connected ? 'green' : 'red';
|
|
23
|
+
const isError = statusText.startsWith('Error:');
|
|
24
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", paddingRight: 1, children: [_jsxs(Box, { height: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "jwcode" }), _jsx(Text, { children: " " }), _jsxs(Text, { backgroundColor: modeColor, color: "black", children: [" ", modeLabel, " "] }), _jsx(Text, { children: " " }), autoMode && (_jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: "magenta", color: "black", children: " AUTO " }), _jsx(Text, { children: " " })] })), _jsxs(Text, { color: connColor, children: [connIcon, " "] }), _jsx(Text, { color: "green", children: model }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [msgCount, "msgs"] }), _jsx(Text, { children: " t: " }), _jsx(Text, { color: "yellow", children: formatTokens(usage.totalTokens) }), _jsx(Text, { children: " " }), _jsxs(Text, { color: pct > 90 ? 'red' : 'white', children: [bar, " ", pct, "%"] })] }), statusText && statusText !== 'connecting...' && (_jsx(Box, { height: 1, children: _jsx(Text, { color: isError ? 'red' : 'grey', dimColor: !isError, children: statusText.slice(0, 100) }) }))] }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function saveToHistory(text: string): void;
|
|
2
|
+
interface Props {
|
|
3
|
+
value: string;
|
|
4
|
+
onChange: (value: string) => void;
|
|
5
|
+
onSubmit: (value: string) => void;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function TextInput({ value, onChange, onSubmit, placeholder, disabled }: Props): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useCallback } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
// Rough token estimation: English ~4 chars/token, CJK ~1.5 chars/token
|
|
5
|
+
function estimateTokens(text) {
|
|
6
|
+
let cjk = 0;
|
|
7
|
+
let other = 0;
|
|
8
|
+
for (const ch of text) {
|
|
9
|
+
if (/[一-鿿㐀-䶿豈- -〿-]/.test(ch)) {
|
|
10
|
+
cjk++;
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
other++;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return Math.ceil(cjk / 1.5 + other / 4);
|
|
17
|
+
}
|
|
18
|
+
const MAX_HISTORY = 30;
|
|
19
|
+
const HISTORY_KEY = 'jwcode-tscli-history';
|
|
20
|
+
function loadHistory() {
|
|
21
|
+
try {
|
|
22
|
+
const raw = process.env.JWCODE_HISTORY
|
|
23
|
+
|| (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(HISTORY_KEY) : null);
|
|
24
|
+
return raw ? JSON.parse(raw) : [];
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function saveHistory(entries) {
|
|
31
|
+
try {
|
|
32
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
33
|
+
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch { /* ignore */ }
|
|
37
|
+
}
|
|
38
|
+
export function saveToHistory(text) {
|
|
39
|
+
const trimmed = text.trim();
|
|
40
|
+
if (!trimmed)
|
|
41
|
+
return;
|
|
42
|
+
const history = loadHistory().filter(h => h !== trimmed);
|
|
43
|
+
history.unshift(trimmed);
|
|
44
|
+
saveHistory(history.slice(0, MAX_HISTORY));
|
|
45
|
+
}
|
|
46
|
+
export function TextInput({ value, onChange, onSubmit, placeholder, disabled }) {
|
|
47
|
+
const historyRef = useRef(loadHistory());
|
|
48
|
+
const histIdxRef = useRef(-1);
|
|
49
|
+
const draftRef = useRef('');
|
|
50
|
+
const navigateHistory = useCallback((dir) => {
|
|
51
|
+
const history = historyRef.current;
|
|
52
|
+
if (history.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
if (histIdxRef.current === -1) {
|
|
55
|
+
draftRef.current = value;
|
|
56
|
+
if (dir === 'up') {
|
|
57
|
+
histIdxRef.current = 0;
|
|
58
|
+
return history[0];
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (dir === 'up') {
|
|
63
|
+
const next = Math.min(histIdxRef.current + 1, history.length - 1);
|
|
64
|
+
histIdxRef.current = next;
|
|
65
|
+
return history[next];
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const next = histIdxRef.current - 1;
|
|
69
|
+
if (next < 0) {
|
|
70
|
+
histIdxRef.current = -1;
|
|
71
|
+
return draftRef.current;
|
|
72
|
+
}
|
|
73
|
+
histIdxRef.current = next;
|
|
74
|
+
return history[next];
|
|
75
|
+
}
|
|
76
|
+
}, [value]);
|
|
77
|
+
const resetHistory = useCallback(() => {
|
|
78
|
+
histIdxRef.current = -1;
|
|
79
|
+
draftRef.current = '';
|
|
80
|
+
}, []);
|
|
81
|
+
useInput((input, key) => {
|
|
82
|
+
if (disabled)
|
|
83
|
+
return;
|
|
84
|
+
if (key.return) {
|
|
85
|
+
onSubmit(value);
|
|
86
|
+
resetHistory();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (key.upArrow) {
|
|
90
|
+
const hist = navigateHistory('up');
|
|
91
|
+
if (hist !== null)
|
|
92
|
+
onChange(hist);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (key.downArrow) {
|
|
96
|
+
const hist = navigateHistory('down');
|
|
97
|
+
if (hist !== null)
|
|
98
|
+
onChange(hist);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Any manual edit resets history navigation
|
|
102
|
+
if (histIdxRef.current !== -1 && input) {
|
|
103
|
+
resetHistory();
|
|
104
|
+
}
|
|
105
|
+
if (key.backspace || key.delete) {
|
|
106
|
+
onChange(value.slice(0, -1));
|
|
107
|
+
resetHistory();
|
|
108
|
+
}
|
|
109
|
+
else if (input && !key.ctrl && !key.meta && !key.tab && !key.escape) {
|
|
110
|
+
onChange(value + input);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
const display = value || '';
|
|
114
|
+
const showPlaceholder = !display && placeholder;
|
|
115
|
+
const tokenEstimate = display ? estimateTokens(display) : 0;
|
|
116
|
+
const charCount = display.length;
|
|
117
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [display ? _jsx(Text, { children: display }) : _jsx(Text, { dimColor: true, children: placeholder }), _jsx(Text, { dimColor: true, children: "\u258A" })] }), charCount > 0 && (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [" ", charCount, " \u5B57\u7B26 \u2248 ", tokenEstimate, " tokens"] }), tokenEstimate > 100000 && (_jsx(Text, { color: "red", children: " \u26A0 \u63A5\u8FD1\u4E0A\u4E0B\u6587\u4E0A\u9650" }))] }))] }));
|
|
118
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Store } from '../store.js';
|
|
2
|
+
import type { Message, TokenUsage } from '../protocol.js';
|
|
3
|
+
export interface AppState {
|
|
4
|
+
messages: Message[];
|
|
5
|
+
currentMessage: Message | null;
|
|
6
|
+
usage: TokenUsage;
|
|
7
|
+
planMode: boolean;
|
|
8
|
+
autoMode: boolean;
|
|
9
|
+
planWaiting: boolean;
|
|
10
|
+
scrollOffset: number;
|
|
11
|
+
modelName: string;
|
|
12
|
+
connected: boolean;
|
|
13
|
+
statusText: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function getStore(): Store<AppState>;
|
|
16
|
+
export declare function useAppState(): AppState;
|
|
17
|
+
export declare function useSetState(): (updater: (prev: AppState) => AppState) => void;
|
|
18
|
+
export declare function updateAppState(updater: (prev: AppState) => AppState): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application state management — React context wrapping the generic store.
|
|
3
|
+
*/
|
|
4
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
5
|
+
import { createStore } from '../store.js';
|
|
6
|
+
const initialState = {
|
|
7
|
+
messages: [],
|
|
8
|
+
currentMessage: null,
|
|
9
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, usageRatio: 0 },
|
|
10
|
+
planMode: false,
|
|
11
|
+
autoMode: false,
|
|
12
|
+
planWaiting: false,
|
|
13
|
+
scrollOffset: 0,
|
|
14
|
+
modelName: '',
|
|
15
|
+
connected: false,
|
|
16
|
+
statusText: 'connecting...',
|
|
17
|
+
};
|
|
18
|
+
let _store = null;
|
|
19
|
+
export function getStore() {
|
|
20
|
+
if (!_store)
|
|
21
|
+
_store = createStore(initialState);
|
|
22
|
+
return _store;
|
|
23
|
+
}
|
|
24
|
+
// Provide context manually since we need to use it outside React
|
|
25
|
+
// We use a module-level store accessed via getStore()
|
|
26
|
+
export function useAppState() {
|
|
27
|
+
const store = getStore();
|
|
28
|
+
const [state, setState] = useState(store.getState());
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
return store.subscribe(() => setState(store.getState()));
|
|
31
|
+
}, []);
|
|
32
|
+
return state;
|
|
33
|
+
}
|
|
34
|
+
export function useSetState() {
|
|
35
|
+
const store = getStore();
|
|
36
|
+
return useCallback((updater) => {
|
|
37
|
+
store.setState(updater);
|
|
38
|
+
}, []);
|
|
39
|
+
}
|
|
40
|
+
export function updateAppState(updater) {
|
|
41
|
+
getStore().setState(updater);
|
|
42
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hyclaw/cli",
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "JWCode — Java AI Coding Tool TypeScript CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/cli.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"hyclaw": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"postinstall": "node scripts/download-jre.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"esbuild": "^0.28.0",
|
|
15
|
+
"ink": "^5.0.0",
|
|
16
|
+
"marked": "^14.1.0",
|
|
17
|
+
"react": "^18.3.1",
|
|
18
|
+
"ws": "^8.18.0"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/",
|
|
22
|
+
"backend/",
|
|
23
|
+
"scripts/download-jre.js",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* postinstall — download & extract platform-specific JRE.
|
|
4
|
+
*
|
|
5
|
+
* Looks for an existing backend/jre/ first; if found, skips.
|
|
6
|
+
* Otherwise detects the current platform and downloads a
|
|
7
|
+
* pre-built JRE archive from a configurable base URL.
|
|
8
|
+
*
|
|
9
|
+
* Environment variables:
|
|
10
|
+
* JWCODE_JRE_BASE_URL Base URL for JRE archives (default: see below)
|
|
11
|
+
* JWCODE_SKIP_JRE Set to "1" to skip JRE download entirely
|
|
12
|
+
* JWCODE_JRE_VERSION Java version tag (default: "17")
|
|
13
|
+
*
|
|
14
|
+
* Default URL pattern:
|
|
15
|
+
* {baseURL}/v{package-version}/jre-{platform}.tar.gz
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, createWriteStream } from 'node:fs';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { pipeline } from 'node:stream/promises';
|
|
22
|
+
import { execSync } from 'node:child_process';
|
|
23
|
+
import { platform, arch } from 'node:os';
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const PACKAGE_DIR = join(__dirname, '..');
|
|
27
|
+
const JRE_DIR = join(PACKAGE_DIR, 'backend', 'jre');
|
|
28
|
+
|
|
29
|
+
// Read version from package.json
|
|
30
|
+
let PACKAGE_VERSION = '0.0.0';
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(
|
|
33
|
+
await import('node:fs').then(fs =>
|
|
34
|
+
fs.promises.readFile(join(PACKAGE_DIR, 'package.json'), 'utf-8')
|
|
35
|
+
)
|
|
36
|
+
);
|
|
37
|
+
PACKAGE_VERSION = pkg.version || PACKAGE_VERSION;
|
|
38
|
+
} catch { /* fallback */ }
|
|
39
|
+
|
|
40
|
+
const BASE_URL = process.env.JWCODE_JRE_BASE_URL || 'https://github.com/jwcode-project/jwcode/releases/download';
|
|
41
|
+
|
|
42
|
+
function detectPlatform() {
|
|
43
|
+
const os = platform();
|
|
44
|
+
const cpu = arch();
|
|
45
|
+
|
|
46
|
+
if (os === 'win32' && cpu === 'x64') return 'win32-x64';
|
|
47
|
+
if (os === 'darwin' && cpu === 'arm64') return 'darwin-arm64';
|
|
48
|
+
if (os === 'darwin' && cpu === 'x64') return 'darwin-x64';
|
|
49
|
+
if (os === 'linux' && cpu === 'x64') return 'linux-x64';
|
|
50
|
+
if (os === 'linux' && cpu === 'arm64') return 'linux-arm64';
|
|
51
|
+
|
|
52
|
+
return null; // unsupported
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function downloadFile(url, destPath) {
|
|
56
|
+
console.log(`[jre] Downloading: ${url}`);
|
|
57
|
+
const response = await fetch(url);
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
const total = parseInt(response.headers.get('content-length') || '0', 10);
|
|
62
|
+
let downloaded = 0;
|
|
63
|
+
let lastLog = 0;
|
|
64
|
+
|
|
65
|
+
// Ensure parent dir exists
|
|
66
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
67
|
+
|
|
68
|
+
const writer = createWriteStream(destPath);
|
|
69
|
+
const reader = response.body;
|
|
70
|
+
|
|
71
|
+
reader.on('data', (chunk) => {
|
|
72
|
+
downloaded += chunk.length;
|
|
73
|
+
if (total && Date.now() - lastLog > 2000) {
|
|
74
|
+
const pct = Math.round((downloaded / total) * 100);
|
|
75
|
+
console.log(`[jre] Progress: ${pct}% (${(downloaded / 1024 / 1024).toFixed(1)} MB)`);
|
|
76
|
+
lastLog = Date.now();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await pipeline(reader, writer);
|
|
81
|
+
console.log(`[jre] Downloaded: ${(downloaded / 1024 / 1024).toFixed(1)} MB`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function extractArchive(archivePath, targetDir) {
|
|
85
|
+
mkdirSync(targetDir, { recursive: true });
|
|
86
|
+
|
|
87
|
+
const ext = archivePath.endsWith('.zip') ? 'zip' : 'targz';
|
|
88
|
+
|
|
89
|
+
console.log(`[jre] Extracting to: ${targetDir}`);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (ext === 'zip') {
|
|
93
|
+
// Use system unzip or PowerShell on Windows
|
|
94
|
+
if (platform() === 'win32') {
|
|
95
|
+
// Windows: use tar (built-in since Win10) to extract .tar.gz, or PowerShell for .zip
|
|
96
|
+
execSync(
|
|
97
|
+
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${targetDir}' -Force"`,
|
|
98
|
+
{ stdio: 'pipe', timeout: 120_000 }
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
execSync(`unzip -o -q "${archivePath}" -d "${targetDir}"`, {
|
|
102
|
+
stdio: 'pipe', timeout: 120_000
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
// tar.gz: use system tar (available on all modern platforms)
|
|
107
|
+
execSync(
|
|
108
|
+
`tar -xzf "${archivePath}" -C "${targetDir}"`,
|
|
109
|
+
{ stdio: 'pipe', timeout: 120_000 }
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Try Node.js native alternative for .tar.gz
|
|
114
|
+
if (ext === 'targz') {
|
|
115
|
+
console.log('[jre] System tar failed, trying Node.js native extraction...');
|
|
116
|
+
await extractTarGzNative(archivePath, targetDir);
|
|
117
|
+
} else {
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Fallback: extract .tar.gz using Node.js built-in zlib + tar-stream.
|
|
125
|
+
* This avoids depending on system tar being available.
|
|
126
|
+
*/
|
|
127
|
+
async function extractTarGzNative(archivePath, targetDir) {
|
|
128
|
+
// We need 'tar' and 'zlib' from npm OR use the built-in zlib + child_process
|
|
129
|
+
// Since the package depends on 'tar-stream' is not guaranteed, try with
|
|
130
|
+
// system commands first, then fall back to a pure-Node approach.
|
|
131
|
+
//
|
|
132
|
+
// On Windows 10/11, 'tar' is built-in. On Linux/macOS it's always there.
|
|
133
|
+
// This fallback is a safety net.
|
|
134
|
+
throw new Error(
|
|
135
|
+
'Could not extract JRE archive. Ensure system tar is available.\n' +
|
|
136
|
+
' Windows 10+: tar is built-in\n' +
|
|
137
|
+
' macOS/Linux: tar is pre-installed\n' +
|
|
138
|
+
` You can also manually extract ${archivePath} to ${targetDir}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function findExistingJRE() {
|
|
143
|
+
if (!existsSync(JRE_DIR)) return false;
|
|
144
|
+
// Check if it looks like a real JRE (has java binary)
|
|
145
|
+
const javaBin = platform() === 'win32'
|
|
146
|
+
? join(JRE_DIR, 'bin', 'java.exe')
|
|
147
|
+
: join(JRE_DIR, 'bin', 'java');
|
|
148
|
+
return existsSync(javaBin);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function main() {
|
|
152
|
+
if (process.env.JWCODE_SKIP_JRE === '1') {
|
|
153
|
+
console.log('[jre] JWCODE_SKIP_JRE=1, skipping JRE download.');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check if JRE already exists
|
|
158
|
+
if (findExistingJRE()) {
|
|
159
|
+
console.log('[jre] JRE already exists at backend/jre/, skipping.');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const plat = detectPlatform();
|
|
164
|
+
if (!plat) {
|
|
165
|
+
console.log(`[jre] Unsupported platform: ${platform()} ${arch()}.`);
|
|
166
|
+
console.log('[jre] System Java 17+ will be required at runtime.');
|
|
167
|
+
console.log('[jre] To skip this check: JWCODE_SKIP_JRE=1 npm install');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Build download URL
|
|
172
|
+
const tag = `v${PACKAGE_VERSION}`;
|
|
173
|
+
const archiveName = `jre-${plat}.tar.gz`;
|
|
174
|
+
const url = `${BASE_URL}/${tag}/${archiveName}`;
|
|
175
|
+
const destDir = join(PACKAGE_DIR, 'backend');
|
|
176
|
+
const destPath = join(destDir, archiveName);
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
console.log(`[jre] Downloading JRE for ${plat}...`);
|
|
180
|
+
await downloadFile(url, destPath);
|
|
181
|
+
await extractArchive(destPath, JRE_DIR);
|
|
182
|
+
console.log('[jre] JRE ready at backend/jre/');
|
|
183
|
+
|
|
184
|
+
// Clean up archive
|
|
185
|
+
try {
|
|
186
|
+
await import('node:fs').then(fs => fs.promises.unlink(destPath));
|
|
187
|
+
} catch { /* ignore */ }
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error(`[jre] Download failed: ${err.message}`);
|
|
190
|
+
console.log('[jre] System Java 17+ will be required at runtime.');
|
|
191
|
+
console.log('[jre] You can also manually download JRE from:');
|
|
192
|
+
console.log(` ${url}`);
|
|
193
|
+
console.log(` and extract it to: backend/jre/`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
main().catch(err => {
|
|
198
|
+
console.error('[jre] Unexpected error:', err.message);
|
|
199
|
+
process.exit(0); // Don't fail the install — fall back to system Java
|
|
200
|
+
});
|
|
201
|
+
|