tabminal 1.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/LICENSE +21 -0
- package/README.md +95 -0
- package/build.mjs +156 -0
- package/config.sample.json +11 -0
- package/icon-gen.html +46 -0
- package/package.json +54 -0
- package/public/app.js +2245 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.svg +23 -0
- package/public/fonts/MonaspaceNeon-Bold.woff2 +0 -0
- package/public/fonts/MonaspaceNeon-Regular.woff2 +0 -0
- package/public/icons/c.svg +1 -0
- package/public/icons/certificate.svg +1 -0
- package/public/icons/console.svg +1 -0
- package/public/icons/cpp.svg +1 -0
- package/public/icons/css.svg +1 -0
- package/public/icons/docker.svg +1 -0
- package/public/icons/document.svg +1 -0
- package/public/icons/folder-src-open.svg +1 -0
- package/public/icons/folder-src.svg +1 -0
- package/public/icons/git.svg +1 -0
- package/public/icons/go.svg +1 -0
- package/public/icons/html.svg +1 -0
- package/public/icons/image.svg +1 -0
- package/public/icons/java.svg +1 -0
- package/public/icons/javascript.svg +1 -0
- package/public/icons/json.svg +1 -0
- package/public/icons/lock.svg +1 -0
- package/public/icons/map.json +52 -0
- package/public/icons/markdown.svg +1 -0
- package/public/icons/nodejs.svg +1 -0
- package/public/icons/php.svg +1 -0
- package/public/icons/python.svg +1 -0
- package/public/icons/react.svg +1 -0
- package/public/icons/react_ts.svg +1 -0
- package/public/icons/readme.svg +1 -0
- package/public/icons/ruby.svg +1 -0
- package/public/icons/rust.svg +1 -0
- package/public/icons/svg.svg +1 -0
- package/public/icons/tsconfig.svg +1 -0
- package/public/icons/tune.svg +1 -0
- package/public/icons/typescript.svg +1 -0
- package/public/icons/xml.svg +1 -0
- package/public/icons/yaml.svg +1 -0
- package/public/index.html +227 -0
- package/public/manifest.json +15 -0
- package/public/styles.css +1267 -0
- package/shell/terminal_ver +12 -0
- package/src/auth.mjs +86 -0
- package/src/config.mjs +199 -0
- package/src/fs-routes.mjs +125 -0
- package/src/persistence.mjs +174 -0
- package/src/server.mjs +306 -0
- package/src/system-monitor.mjs +108 -0
- package/src/terminal-manager.mjs +283 -0
- package/src/terminal-session.mjs +984 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|
3
|
+
ROOT_DIR="$(dirname "$DIR")"
|
|
4
|
+
PACKAGE_JSON="$ROOT_DIR/package.json"
|
|
5
|
+
|
|
6
|
+
if [ -f "$PACKAGE_JSON" ]; then
|
|
7
|
+
NAME=$(grep -o '"name": *"[^"]*"' "$PACKAGE_JSON" | cut -d'"' -f4)
|
|
8
|
+
VERSION=$(grep -o '"version": *"[^"]*"' "$PACKAGE_JSON" | cut -d'"' -f4)
|
|
9
|
+
echo "$NAME v$VERSION"
|
|
10
|
+
else
|
|
11
|
+
echo "Error: package.json not found at $PACKAGE_JSON"
|
|
12
|
+
fi
|
package/src/auth.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { config } from './config.mjs';
|
|
2
|
+
|
|
3
|
+
let failedAttempts = 0;
|
|
4
|
+
let isLocked = false;
|
|
5
|
+
const MAX_ATTEMPTS = 30;
|
|
6
|
+
|
|
7
|
+
export function checkAuth(providedHash) {
|
|
8
|
+
if (isLocked) {
|
|
9
|
+
return { success: false, locked: true };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!providedHash || providedHash !== config.passwordHash) {
|
|
13
|
+
failedAttempts++;
|
|
14
|
+
if (failedAttempts >= MAX_ATTEMPTS) {
|
|
15
|
+
isLocked = true;
|
|
16
|
+
console.error('[Auth] Maximum failed attempts reached. Service locked.');
|
|
17
|
+
}
|
|
18
|
+
return { success: false, locked: isLocked };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Reset attempts on success?
|
|
22
|
+
// Requirement says "already wrong 30 times, service locked".
|
|
23
|
+
// Usually success resets counter, but strict interpretation might mean cumulative.
|
|
24
|
+
// Assuming standard behavior: success resets counter to avoid accidental lockout over long periods.
|
|
25
|
+
failedAttempts = 0;
|
|
26
|
+
return { success: true, locked: false };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function authMiddleware(ctx, next) {
|
|
30
|
+
// Allow health check without auth
|
|
31
|
+
if (ctx.path === '/healthz') {
|
|
32
|
+
return next();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check for Authorization header
|
|
36
|
+
const authHeader = ctx.get('Authorization') || ctx.query.token;
|
|
37
|
+
// Expecting "Authorization: <sha256-hash>"
|
|
38
|
+
// Some clients might send "Bearer <hash>", let's handle raw hash for simplicity as per prompt
|
|
39
|
+
// "header中攜帶這個編碼過的密碼" -> implies the value is the hash.
|
|
40
|
+
|
|
41
|
+
const { success, locked } = checkAuth(authHeader);
|
|
42
|
+
|
|
43
|
+
if (locked) {
|
|
44
|
+
ctx.status = 403;
|
|
45
|
+
ctx.body = { error: 'Service locked due to too many failed attempts. Please restart the service.' };
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!success) {
|
|
50
|
+
ctx.status = 401;
|
|
51
|
+
ctx.body = { error: 'Unauthorized' };
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await next();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function verifyClient(info, cb) {
|
|
59
|
+
const { req } = info;
|
|
60
|
+
// WebSocket headers are in req.headers
|
|
61
|
+
let authHeader = req.headers['authorization'];
|
|
62
|
+
|
|
63
|
+
// If no header, check query parameter
|
|
64
|
+
if (!authHeader && req.url) {
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
67
|
+
authHeader = url.searchParams.get('token');
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// ignore invalid url
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { success, locked } = checkAuth(authHeader);
|
|
74
|
+
|
|
75
|
+
if (locked) {
|
|
76
|
+
cb(false, 403, 'Service locked');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!success) {
|
|
81
|
+
cb(false, 401, 'Unauthorized');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
cb(true);
|
|
86
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CONFIG = {
|
|
8
|
+
host: '127.0.0.1',
|
|
9
|
+
port: 9846,
|
|
10
|
+
heartbeatInterval: 10000,
|
|
11
|
+
historyLimit: 524288,
|
|
12
|
+
acceptTerms: false,
|
|
13
|
+
password: null,
|
|
14
|
+
model: 'gemini-2.5-flash-preview-09-2025',
|
|
15
|
+
debug: false,
|
|
16
|
+
openrouterKey: null,
|
|
17
|
+
googleKey: null,
|
|
18
|
+
googleCx: null
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function loadJson(filePath) {
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(filePath)) {
|
|
24
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
25
|
+
return JSON.parse(content);
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.warn(`[Config] Failed to load config from ${filePath}:`, error.message);
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function generateRandomPassword(length = 32) {
|
|
34
|
+
return crypto.randomBytes(Math.ceil(length / 2)).toString('hex').slice(0, length);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sha256(input) {
|
|
38
|
+
return crypto.createHash('sha256').update(input).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadConfig() {
|
|
42
|
+
// 1. Load from ~/.tabminal/config.json
|
|
43
|
+
const configDir = path.join(os.homedir(), '.tabminal');
|
|
44
|
+
const homeConfigPath = path.join(configDir, 'config.json');
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.existsSync(configDir)) {
|
|
48
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.warn('[Config] Failed to create config directory:', e.message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const homeConfig = loadJson(homeConfigPath);
|
|
55
|
+
|
|
56
|
+
// 2. Load from ./config.json
|
|
57
|
+
const localConfigPath = path.join(process.cwd(), 'config.json');
|
|
58
|
+
const localConfig = loadJson(localConfigPath);
|
|
59
|
+
|
|
60
|
+
// 3. Parse CLI arguments
|
|
61
|
+
const { values: args } = parseArgs({
|
|
62
|
+
options: {
|
|
63
|
+
host: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
short: 'h'
|
|
66
|
+
},
|
|
67
|
+
port: {
|
|
68
|
+
type: 'string', // Parse as string first to handle potential non-numeric input safely
|
|
69
|
+
short: 'p'
|
|
70
|
+
},
|
|
71
|
+
password: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
short: 'a'
|
|
74
|
+
},
|
|
75
|
+
'openrouter-key': {
|
|
76
|
+
type: 'string',
|
|
77
|
+
short: 'k'
|
|
78
|
+
},
|
|
79
|
+
model: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
short: 'm'
|
|
82
|
+
},
|
|
83
|
+
debug: {
|
|
84
|
+
type: 'boolean',
|
|
85
|
+
short: 'd'
|
|
86
|
+
},
|
|
87
|
+
'google-key': {
|
|
88
|
+
type: 'string',
|
|
89
|
+
short: 'g'
|
|
90
|
+
},
|
|
91
|
+
'google-cx': {
|
|
92
|
+
type: 'string',
|
|
93
|
+
short: 'c'
|
|
94
|
+
},
|
|
95
|
+
help: {
|
|
96
|
+
type: 'boolean'
|
|
97
|
+
},
|
|
98
|
+
'accept-terms': {
|
|
99
|
+
type: 'boolean',
|
|
100
|
+
short: 'y'
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
strict: false // Allow other args if necessary
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (args.help) {
|
|
107
|
+
console.log(`
|
|
108
|
+
Tabminal - A modern web terminal
|
|
109
|
+
|
|
110
|
+
Usage:
|
|
111
|
+
node src/server.mjs [options]
|
|
112
|
+
|
|
113
|
+
Options:
|
|
114
|
+
--host, -h Host to bind to (default: 127.0.0.1)
|
|
115
|
+
--port, -p Port to listen on (default: 9846)
|
|
116
|
+
--password, -a Set access password
|
|
117
|
+
--openrouter-key, -k Set OpenRouter API Key
|
|
118
|
+
--model, -m Set AI Model
|
|
119
|
+
--google-key, -g Set Google Search API Key
|
|
120
|
+
--google-cx, -c Set Google Search Engine ID
|
|
121
|
+
--debug, -d Enable debug mode
|
|
122
|
+
--accept-terms, -y Accept security warning and start server
|
|
123
|
+
--help Show this help message
|
|
124
|
+
`);
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Merge configurations: Defaults < Home < Local < CLI
|
|
129
|
+
const finalConfig = {
|
|
130
|
+
...DEFAULT_CONFIG,
|
|
131
|
+
...homeConfig,
|
|
132
|
+
...localConfig
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Normalize config keys (support kebab-case in JSON)
|
|
136
|
+
if (finalConfig['accept-terms']) finalConfig.acceptTerms = finalConfig['accept-terms'];
|
|
137
|
+
if (finalConfig['openrouter-key']) finalConfig.openrouterKey = finalConfig['openrouter-key'];
|
|
138
|
+
if (finalConfig['ai-key']) finalConfig.openrouterKey = finalConfig['ai-key']; // Backwards compat
|
|
139
|
+
if (finalConfig['google-key']) finalConfig.googleKey = finalConfig['google-key'];
|
|
140
|
+
if (finalConfig['google-cx']) finalConfig.googleCx = finalConfig['google-cx'];
|
|
141
|
+
|
|
142
|
+
if (args.host) {
|
|
143
|
+
finalConfig.host = args.host;
|
|
144
|
+
}
|
|
145
|
+
if (args.port) {
|
|
146
|
+
const parsedPort = parseInt(args.port, 10);
|
|
147
|
+
if (!isNaN(parsedPort)) {
|
|
148
|
+
finalConfig.port = parsedPort;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (args['accept-terms']) {
|
|
152
|
+
finalConfig.acceptTerms = true;
|
|
153
|
+
}
|
|
154
|
+
if (args.password) {
|
|
155
|
+
finalConfig.password = args.password;
|
|
156
|
+
}
|
|
157
|
+
if (args['openrouter-key']) {
|
|
158
|
+
finalConfig.openrouterKey = args['openrouter-key'];
|
|
159
|
+
}
|
|
160
|
+
if (args.model) {
|
|
161
|
+
finalConfig.model = args.model;
|
|
162
|
+
}
|
|
163
|
+
if (args.debug) {
|
|
164
|
+
finalConfig.debug = true;
|
|
165
|
+
}
|
|
166
|
+
if (args['google-key']) {
|
|
167
|
+
finalConfig.googleKey = args['google-key'];
|
|
168
|
+
}
|
|
169
|
+
if (args['google-cx']) {
|
|
170
|
+
finalConfig.googleCx = args['google-cx'];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Environment variables override (for backward compatibility/container usage)
|
|
174
|
+
if (process.env.HOST) finalConfig.host = process.env.HOST;
|
|
175
|
+
if (process.env.PORT) finalConfig.port = parseInt(process.env.PORT, 10);
|
|
176
|
+
if (process.env.TABMINAL_HEARTBEAT) finalConfig.heartbeatInterval = parseInt(process.env.TABMINAL_HEARTBEAT, 10);
|
|
177
|
+
if (process.env.TABMINAL_HISTORY) finalConfig.historyLimit = parseInt(process.env.TABMINAL_HISTORY, 10);
|
|
178
|
+
if (process.env.TABMINAL_PASSWORD) finalConfig.password = process.env.TABMINAL_PASSWORD;
|
|
179
|
+
if (process.env.TABMINAL_OPENROUTER_KEY) finalConfig.openrouterKey = process.env.TABMINAL_OPENROUTER_KEY;
|
|
180
|
+
if (process.env.TABMINAL_MODEL) finalConfig.model = process.env.TABMINAL_MODEL;
|
|
181
|
+
if (process.env.TABMINAL_DEBUG) finalConfig.debug = true;
|
|
182
|
+
if (process.env.TABMINAL_GOOGLE_KEY) finalConfig.googleKey = process.env.TABMINAL_GOOGLE_KEY;
|
|
183
|
+
if (process.env.TABMINAL_GOOGLE_CX) finalConfig.googleCx = process.env.TABMINAL_GOOGLE_CX;
|
|
184
|
+
|
|
185
|
+
// Password Logic
|
|
186
|
+
if (!finalConfig.password) {
|
|
187
|
+
finalConfig.password = generateRandomPassword();
|
|
188
|
+
console.log('\n[SECURITY] No password provided. Generated temporary password:');
|
|
189
|
+
console.log(`\x1b[36m${finalConfig.password}\x1b[0m`);
|
|
190
|
+
console.log('Please save this password or set a custom one using -a/--passwd.\n');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Store SHA256 hash in memory
|
|
194
|
+
finalConfig.passwordHash = sha256(finalConfig.password);
|
|
195
|
+
|
|
196
|
+
return finalConfig;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const config = loadConfig();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
|
|
6
|
+
// Helper to safely resolve path
|
|
7
|
+
const resolvePath = (baseDir, targetPath) => {
|
|
8
|
+
return path.resolve(baseDir, targetPath);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const setupFsRoutes = (router) => {
|
|
12
|
+
const baseDir = process.cwd(); // Or config.homeDir if you want to restrict/change it
|
|
13
|
+
|
|
14
|
+
// List directory
|
|
15
|
+
router.get('/api/fs/list', async (ctx) => {
|
|
16
|
+
const dirPath = ctx.query.path || '.';
|
|
17
|
+
try {
|
|
18
|
+
const fullPath = resolvePath(baseDir, dirPath);
|
|
19
|
+
const stats = await fs.stat(fullPath);
|
|
20
|
+
|
|
21
|
+
if (!stats.isDirectory()) {
|
|
22
|
+
ctx.status = 400;
|
|
23
|
+
ctx.body = { error: 'Not a directory' };
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const dirents = await fs.readdir(fullPath, { withFileTypes: true });
|
|
28
|
+
|
|
29
|
+
const files = dirents
|
|
30
|
+
.filter(dirent => dirent.name !== '.DS_Store')
|
|
31
|
+
.map(dirent => {
|
|
32
|
+
return {
|
|
33
|
+
name: dirent.name,
|
|
34
|
+
isDirectory: dirent.isDirectory(),
|
|
35
|
+
path: path.join(dirPath, dirent.name),
|
|
36
|
+
// Add basic icon/type hint logic here if needed later
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Sort: Directories first, then files
|
|
41
|
+
files.sort((a, b) => {
|
|
42
|
+
if (a.isDirectory === b.isDirectory) {
|
|
43
|
+
return a.name.localeCompare(b.name);
|
|
44
|
+
}
|
|
45
|
+
return a.isDirectory ? -1 : 1;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
ctx.body = files;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error('FS List Error:', err);
|
|
51
|
+
ctx.status = 500;
|
|
52
|
+
ctx.body = { error: err.message };
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Read file
|
|
57
|
+
router.get('/api/fs/read', async (ctx) => {
|
|
58
|
+
const filePath = ctx.query.path;
|
|
59
|
+
if (!filePath) {
|
|
60
|
+
ctx.status = 400;
|
|
61
|
+
ctx.body = { error: 'Path required' };
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const fullPath = resolvePath(baseDir, filePath);
|
|
67
|
+
const stats = await fs.stat(fullPath);
|
|
68
|
+
|
|
69
|
+
if (stats.size > 1024 * 1024 * 5) { // 5MB limit for now
|
|
70
|
+
ctx.status = 400;
|
|
71
|
+
ctx.body = { error: 'File too large' };
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
76
|
+
|
|
77
|
+
let readonly = false;
|
|
78
|
+
try {
|
|
79
|
+
const handle = await fs.open(fullPath, 'r+');
|
|
80
|
+
await handle.close();
|
|
81
|
+
} catch (e) {
|
|
82
|
+
readonly = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
ctx.body = { content, readonly };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error('FS Read Error:', err);
|
|
88
|
+
ctx.status = 500;
|
|
89
|
+
ctx.body = { error: err.message };
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Raw file access (for images)
|
|
94
|
+
router.get('/api/fs/raw', async (ctx) => {
|
|
95
|
+
const filePath = ctx.query.path;
|
|
96
|
+
if (!filePath) {
|
|
97
|
+
ctx.status = 400;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const fullPath = resolvePath(baseDir, filePath);
|
|
103
|
+
// Basic mime type handling could be added here
|
|
104
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
105
|
+
const mimeTypes = {
|
|
106
|
+
'.png': 'image/png',
|
|
107
|
+
'.jpg': 'image/jpeg',
|
|
108
|
+
'.jpeg': 'image/jpeg',
|
|
109
|
+
'.gif': 'image/gif',
|
|
110
|
+
'.svg': 'image/svg+xml',
|
|
111
|
+
'.webp': 'image/webp'
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (mimeTypes[ext]) {
|
|
115
|
+
ctx.type = mimeTypes[ext];
|
|
116
|
+
ctx.body = await fs.readFile(fullPath);
|
|
117
|
+
} else {
|
|
118
|
+
ctx.status = 400;
|
|
119
|
+
ctx.body = 'Unsupported file type for raw access';
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
ctx.status = 404;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
const HOME_DIR = os.homedir();
|
|
6
|
+
const BASE_DIR = path.join(HOME_DIR, '.tabminal');
|
|
7
|
+
const SESSIONS_DIR = path.join(BASE_DIR, 'sessions');
|
|
8
|
+
const MEMORY_FILE = path.join(BASE_DIR, 'memory.json');
|
|
9
|
+
|
|
10
|
+
// Ensure directories exist
|
|
11
|
+
const init = async () => {
|
|
12
|
+
try {
|
|
13
|
+
await fs.mkdir(SESSIONS_DIR, { recursive: true });
|
|
14
|
+
} catch (e) {
|
|
15
|
+
console.error('[Persistence] Failed to create directories:', e);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// --- Session Persistence ---
|
|
20
|
+
|
|
21
|
+
export const saveSession = async (id, data) => {
|
|
22
|
+
await init();
|
|
23
|
+
const filePath = path.join(SESSIONS_DIR, `${id}.json`);
|
|
24
|
+
try {
|
|
25
|
+
// We only save serializable data
|
|
26
|
+
const serializable = {
|
|
27
|
+
id: data.id,
|
|
28
|
+
title: data.title,
|
|
29
|
+
cwd: data.cwd,
|
|
30
|
+
env: data.env,
|
|
31
|
+
cols: data.cols,
|
|
32
|
+
rows: data.rows,
|
|
33
|
+
createdAt: data.createdAt,
|
|
34
|
+
// Editor State
|
|
35
|
+
editorState: data.editorState || {},
|
|
36
|
+
executions: data.executions || []
|
|
37
|
+
};
|
|
38
|
+
await fs.writeFile(filePath, JSON.stringify(serializable, null, 2));
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error(`[Persistence] Failed to save session ${id}:`, e);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const deleteSession = async (id) => {
|
|
45
|
+
const jsonPath = path.join(SESSIONS_DIR, `${id}.json`);
|
|
46
|
+
const logPath = path.join(SESSIONS_DIR, `${id}.log`);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await fs.unlink(jsonPath);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e.code !== 'ENOENT') console.error(`[Persistence] Failed to delete session config ${id}:`, e);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await fs.unlink(logPath);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
if (e.code !== 'ENOENT') console.error(`[Persistence] Failed to delete session log ${id}:`, e);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const loadSessions = async () => {
|
|
62
|
+
await init();
|
|
63
|
+
try {
|
|
64
|
+
const files = await fs.readdir(SESSIONS_DIR);
|
|
65
|
+
const sessions = [];
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
if (file.endsWith('.json')) {
|
|
68
|
+
try {
|
|
69
|
+
const content = await fs.readFile(path.join(SESSIONS_DIR, file), 'utf-8');
|
|
70
|
+
sessions.push(JSON.parse(content));
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.warn(`[Persistence] Failed to parse session file ${file}, deleting it:`, e);
|
|
73
|
+
try {
|
|
74
|
+
await fs.unlink(path.join(SESSIONS_DIR, file));
|
|
75
|
+
} catch (delErr) {
|
|
76
|
+
console.error(`[Persistence] Failed to delete corrupted file ${file}:`, delErr);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Sort by creation time if possible, or just return
|
|
82
|
+
return sessions.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error('[Persistence] Failed to load sessions:', e);
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// --- Memory Persistence (Global State) ---
|
|
90
|
+
|
|
91
|
+
const defaultMemory = {
|
|
92
|
+
expandedFolders: [] // Array of { path: string, timestamp: number }
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const loadMemory = async () => {
|
|
96
|
+
await init();
|
|
97
|
+
try {
|
|
98
|
+
const content = await fs.readFile(MEMORY_FILE, 'utf-8');
|
|
99
|
+
return { ...defaultMemory, ...JSON.parse(content) };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return defaultMemory;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const saveMemory = async (memory) => {
|
|
106
|
+
await init();
|
|
107
|
+
try {
|
|
108
|
+
await fs.writeFile(MEMORY_FILE, JSON.stringify(memory, null, 2));
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.error('[Persistence] Failed to save memory:', e);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const updateExpandedFolder = async (folderPath, isExpanded) => {
|
|
115
|
+
const memory = await loadMemory();
|
|
116
|
+
let list = memory.expandedFolders || [];
|
|
117
|
+
|
|
118
|
+
if (isExpanded) {
|
|
119
|
+
// Remove existing if present (to update timestamp/position)
|
|
120
|
+
list = list.filter(item => item.path !== folderPath);
|
|
121
|
+
// Add to top
|
|
122
|
+
list.unshift({ path: folderPath, timestamp: Date.now() });
|
|
123
|
+
// Limit to 100
|
|
124
|
+
if (list.length > 100) {
|
|
125
|
+
list = list.slice(0, 100);
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
// Remove
|
|
129
|
+
list = list.filter(item => item.path !== folderPath);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
memory.expandedFolders = list;
|
|
133
|
+
await saveMemory(memory);
|
|
134
|
+
return list.map(item => item.path); // Return just paths for frontend
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const getExpandedFolders = async () => {
|
|
138
|
+
const memory = await loadMemory();
|
|
139
|
+
return (memory.expandedFolders || []).map(item => item.path);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// --- Raw Log Persistence ---
|
|
143
|
+
|
|
144
|
+
export const appendSessionLog = async (id, chunk) => {
|
|
145
|
+
await init();
|
|
146
|
+
const filePath = path.join(SESSIONS_DIR, `${id}.log`);
|
|
147
|
+
try {
|
|
148
|
+
await fs.appendFile(filePath, chunk);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
console.error(`[Persistence] Failed to append log for ${id}:`, e);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const loadSessionLog = async (id, limit = 1024 * 1024) => {
|
|
155
|
+
const filePath = path.join(SESSIONS_DIR, `${id}.log`);
|
|
156
|
+
try {
|
|
157
|
+
const stats = await fs.stat(filePath);
|
|
158
|
+
const size = stats.size;
|
|
159
|
+
const start = Math.max(0, size - limit);
|
|
160
|
+
const length = size - start;
|
|
161
|
+
|
|
162
|
+
if (length <= 0) return '';
|
|
163
|
+
|
|
164
|
+
const handle = await fs.open(filePath, 'r');
|
|
165
|
+
const buffer = Buffer.alloc(length);
|
|
166
|
+
await handle.read(buffer, 0, length, start);
|
|
167
|
+
await handle.close();
|
|
168
|
+
|
|
169
|
+
return buffer.toString('utf-8');
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (e.code !== 'ENOENT') console.error(`[Persistence] Failed to load log for ${id}:`, e);
|
|
172
|
+
return '';
|
|
173
|
+
}
|
|
174
|
+
};
|