labgate 0.5.28 → 0.5.29
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 +67 -158
- package/dist/cli.js +0 -142
- package/dist/cli.js.map +1 -1
- package/dist/lib/container.d.ts +2 -2
- package/dist/lib/container.js +37 -5
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/init.js +56 -1
- package/dist/lib/init.js.map +1 -1
- package/dist/lib/test/integration-harness.d.ts +2 -1
- package/dist/lib/test/integration-harness.js +8 -27
- package/dist/lib/test/integration-harness.js.map +1 -1
- package/dist/lib/ui.html +2941 -1032
- package/dist/lib/ui.js +393 -2
- package/dist/lib/ui.js.map +1 -1
- package/dist/lib/web-terminal.js +23 -9
- package/dist/lib/web-terminal.js.map +1 -1
- package/package.json +2 -1
package/dist/lib/ui.js
CHANGED
|
@@ -58,6 +58,7 @@ const FONTS_DIR = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', 'ge
|
|
|
58
58
|
const XTERM_CSS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css');
|
|
59
59
|
const XTERM_JS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'xterm', 'lib', 'xterm.js');
|
|
60
60
|
const XTERM_FIT_JS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'addon-fit', 'lib', 'addon-fit.js');
|
|
61
|
+
const XTERM_WEB_LINKS_JS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'addon-web-links', 'lib', 'addon-web-links.js');
|
|
61
62
|
const WRITE_TOKEN_PLACEHOLDER = '__LABGATE_WRITE_TOKEN__';
|
|
62
63
|
const UI_WRITE_TOKEN = (0, crypto_1.randomBytes)(24).toString('hex');
|
|
63
64
|
const UI_AUTH_COOKIE = 'labgate_ui_token';
|
|
@@ -65,10 +66,25 @@ const UI_SHORT_LINK_PREFIX = '/s/';
|
|
|
65
66
|
const DASHBOARD_LINK_FILE = '.labgate-dashboard-url';
|
|
66
67
|
const LABGATE_INSTRUCTION_START = '<!-- LABGATE_SESSION_INSTRUCTION_START -->';
|
|
67
68
|
const LABGATE_INSTRUCTION_END = '<!-- LABGATE_SESSION_INSTRUCTION_END -->';
|
|
69
|
+
const IRIS_SAMPLE_DATASET_NAME = 'flowers-iris';
|
|
70
|
+
const IRIS_SAMPLE_SOURCE_URL = 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv';
|
|
71
|
+
const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
|
|
72
|
+
'\n' +
|
|
73
|
+
'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
|
|
74
|
+
'\n' +
|
|
75
|
+
'This dataset is a small sample for testing LabGate dataset registration.\n' +
|
|
76
|
+
'Rows: 150 iris flower measurements across three classes.\n';
|
|
77
|
+
function resolveIrisSampleSourceUrl() {
|
|
78
|
+
const override = (process.env.LABGATE_IRIS_SAMPLE_SOURCE_URL || '').trim();
|
|
79
|
+
if (/^https?:\/\//i.test(override))
|
|
80
|
+
return override;
|
|
81
|
+
return IRIS_SAMPLE_SOURCE_URL;
|
|
82
|
+
}
|
|
68
83
|
// ── SLURM module state (initialised in startUI when slurm.enabled) ──
|
|
69
84
|
let slurmDB = null;
|
|
70
85
|
let slurmPoller = null;
|
|
71
86
|
let resultsStore = null;
|
|
87
|
+
const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
|
|
72
88
|
const webTerminalBridges = new Map();
|
|
73
89
|
function getResultsStore() {
|
|
74
90
|
if (!resultsStore) {
|
|
@@ -76,6 +92,50 @@ function getResultsStore() {
|
|
|
76
92
|
}
|
|
77
93
|
return resultsStore;
|
|
78
94
|
}
|
|
95
|
+
function hasCommandInPath(command) {
|
|
96
|
+
const pathValue = (process.env.PATH || '').trim();
|
|
97
|
+
if (!pathValue)
|
|
98
|
+
return false;
|
|
99
|
+
const isWindows = (0, os_1.platform)() === 'win32';
|
|
100
|
+
const directories = pathValue.split(path_1.delimiter).filter(Boolean);
|
|
101
|
+
const hasExplicitExtension = /\.[^/\\]+$/.test(command);
|
|
102
|
+
const windowsExtensions = isWindows
|
|
103
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
|
104
|
+
.split(';')
|
|
105
|
+
.map((ext) => ext.trim().toLowerCase())
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
: [''];
|
|
108
|
+
for (const dir of directories) {
|
|
109
|
+
const candidates = isWindows
|
|
110
|
+
? (hasExplicitExtension ? [command] : windowsExtensions.map((ext) => `${command}${ext}`))
|
|
111
|
+
: [command];
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
const fullPath = (0, path_1.join)(dir, candidate);
|
|
114
|
+
if (!(0, fs_1.existsSync)(fullPath))
|
|
115
|
+
continue;
|
|
116
|
+
try {
|
|
117
|
+
const st = (0, fs_1.statSync)(fullPath);
|
|
118
|
+
if (!st.isFile())
|
|
119
|
+
continue;
|
|
120
|
+
if (isWindows)
|
|
121
|
+
return true;
|
|
122
|
+
(0, fs_1.accessSync)(fullPath, fs_1.constants.X_OK);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Continue scanning PATH entries.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
function getSlurmRuntimeStatus() {
|
|
133
|
+
const missingCommands = REQUIRED_SLURM_COMMANDS.filter((command) => !hasCommandInPath(command));
|
|
134
|
+
return {
|
|
135
|
+
available: missingCommands.length === 0,
|
|
136
|
+
missingCommands,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
79
139
|
function readBody(req) {
|
|
80
140
|
return new Promise((resolve, reject) => {
|
|
81
141
|
const chunks = [];
|
|
@@ -88,6 +148,103 @@ function json(res, data, status = 200) {
|
|
|
88
148
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
89
149
|
res.end(JSON.stringify(data));
|
|
90
150
|
}
|
|
151
|
+
function normalizeEmailCandidate(value) {
|
|
152
|
+
if (typeof value !== 'string')
|
|
153
|
+
return null;
|
|
154
|
+
const match = value.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
|
|
155
|
+
if (!match)
|
|
156
|
+
return null;
|
|
157
|
+
return match[0].toLowerCase();
|
|
158
|
+
}
|
|
159
|
+
function deepFindEmail(value, depth = 0) {
|
|
160
|
+
if (depth > 6 || value === null || value === undefined)
|
|
161
|
+
return null;
|
|
162
|
+
const direct = normalizeEmailCandidate(value);
|
|
163
|
+
if (direct)
|
|
164
|
+
return direct;
|
|
165
|
+
if (Array.isArray(value)) {
|
|
166
|
+
for (const item of value) {
|
|
167
|
+
const found = deepFindEmail(item, depth + 1);
|
|
168
|
+
if (found)
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
if (typeof value !== 'object')
|
|
174
|
+
return null;
|
|
175
|
+
const record = value;
|
|
176
|
+
const keys = Object.keys(record);
|
|
177
|
+
for (const key of keys) {
|
|
178
|
+
if (!/email|mail/i.test(key))
|
|
179
|
+
continue;
|
|
180
|
+
const found = deepFindEmail(record[key], depth + 1);
|
|
181
|
+
if (found)
|
|
182
|
+
return found;
|
|
183
|
+
}
|
|
184
|
+
for (const key of keys) {
|
|
185
|
+
const found = deepFindEmail(record[key], depth + 1);
|
|
186
|
+
if (found)
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
function deepFindAccessToken(value, depth = 0) {
|
|
192
|
+
if (depth > 6 || value === null || value === undefined)
|
|
193
|
+
return '';
|
|
194
|
+
if (typeof value === 'string') {
|
|
195
|
+
const token = value.trim();
|
|
196
|
+
return token.length >= 20 ? token : '';
|
|
197
|
+
}
|
|
198
|
+
if (Array.isArray(value)) {
|
|
199
|
+
for (const item of value) {
|
|
200
|
+
const found = deepFindAccessToken(item, depth + 1);
|
|
201
|
+
if (found)
|
|
202
|
+
return found;
|
|
203
|
+
}
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
if (typeof value !== 'object')
|
|
207
|
+
return '';
|
|
208
|
+
const record = value;
|
|
209
|
+
const keys = Object.keys(record);
|
|
210
|
+
for (const key of keys) {
|
|
211
|
+
if (!/access.?token|token/i.test(key))
|
|
212
|
+
continue;
|
|
213
|
+
const found = deepFindAccessToken(record[key], depth + 1);
|
|
214
|
+
if (found)
|
|
215
|
+
return found;
|
|
216
|
+
}
|
|
217
|
+
return '';
|
|
218
|
+
}
|
|
219
|
+
function readClaudeCredentialSnapshot() {
|
|
220
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
221
|
+
const credentialPaths = [
|
|
222
|
+
(0, path_1.join)(sandboxHome, '.claude', '.credentials.json'),
|
|
223
|
+
(0, path_1.join)(sandboxHome, '.claude', 'credentials.json'),
|
|
224
|
+
];
|
|
225
|
+
for (const credPath of credentialPaths) {
|
|
226
|
+
if (!(0, fs_1.existsSync)(credPath))
|
|
227
|
+
continue;
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(credPath, 'utf-8'));
|
|
230
|
+
const nestedOauth = parsed.claudeAiOauth ?? parsed.oauth ?? null;
|
|
231
|
+
const token = (typeof nestedOauth?.accessToken === 'string' && nestedOauth.accessToken.trim()) ||
|
|
232
|
+
(typeof parsed.accessToken === 'string' && parsed.accessToken.trim()) ||
|
|
233
|
+
deepFindAccessToken(parsed);
|
|
234
|
+
if (!token)
|
|
235
|
+
continue;
|
|
236
|
+
const email = deepFindEmail(nestedOauth?.email) ||
|
|
237
|
+
deepFindEmail(nestedOauth?.user) ||
|
|
238
|
+
deepFindEmail(nestedOauth?.profile) ||
|
|
239
|
+
deepFindEmail(parsed);
|
|
240
|
+
return { loggedIn: true, email: email || null };
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Ignore malformed credential files and keep searching.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { loggedIn: false, email: null };
|
|
247
|
+
}
|
|
91
248
|
function getHeaderValue(req, name) {
|
|
92
249
|
const value = req.headers[name.toLowerCase()];
|
|
93
250
|
if (Array.isArray(value))
|
|
@@ -176,6 +333,39 @@ function clearDashboardLink(expectedUrl) {
|
|
|
176
333
|
// Best effort cleanup.
|
|
177
334
|
}
|
|
178
335
|
}
|
|
336
|
+
function supportsTerminalHyperlinks() {
|
|
337
|
+
if (!(process.stderr.isTTY ?? false))
|
|
338
|
+
return false;
|
|
339
|
+
if (process.env.LABGATE_UI_HYPERLINKS === '0')
|
|
340
|
+
return false;
|
|
341
|
+
if ((process.env.TERM || '').toLowerCase() === 'dumb')
|
|
342
|
+
return false;
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
function formatTerminalHyperlink(url) {
|
|
346
|
+
if (!supportsTerminalHyperlinks())
|
|
347
|
+
return url;
|
|
348
|
+
const safe = url.replace(/[\u001b\u0007]/g, '');
|
|
349
|
+
return `\u001b]8;;${safe}\u0007${url}\u001b]8;;\u0007`;
|
|
350
|
+
}
|
|
351
|
+
function shouldAutoOpenUiBrowser(standalone) {
|
|
352
|
+
if (!standalone)
|
|
353
|
+
return false;
|
|
354
|
+
if (process.env.LABGATE_UI_AUTO_OPEN === '0')
|
|
355
|
+
return false;
|
|
356
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
|
|
357
|
+
return false;
|
|
358
|
+
if (!(process.stdout.isTTY ?? false) && !(process.stderr.isTTY ?? false))
|
|
359
|
+
return false;
|
|
360
|
+
return (0, os_1.platform)() === 'darwin';
|
|
361
|
+
}
|
|
362
|
+
function autoOpenUiBrowser(url) {
|
|
363
|
+
(0, child_process_1.execFile)('open', [url], (err) => {
|
|
364
|
+
if (!err)
|
|
365
|
+
return;
|
|
366
|
+
log.warn('Could not auto-open browser. Use the Settings URL above.');
|
|
367
|
+
});
|
|
368
|
+
}
|
|
179
369
|
function serveFontFile(url, res) {
|
|
180
370
|
// Only allow specific font files from the geist package
|
|
181
371
|
const match = url.match(/^\/fonts\/([\w-]+\.woff2)$/);
|
|
@@ -378,6 +568,12 @@ function serveHTML(res) {
|
|
|
378
568
|
function handleGetConfig(_req, res) {
|
|
379
569
|
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
380
570
|
const response = { ...effective.config };
|
|
571
|
+
const slurmRuntime = getSlurmRuntimeStatus();
|
|
572
|
+
response._slurm = {
|
|
573
|
+
available: slurmRuntime.available,
|
|
574
|
+
missing_commands: slurmRuntime.missingCommands,
|
|
575
|
+
required_commands: [...REQUIRED_SLURM_COMMANDS],
|
|
576
|
+
};
|
|
381
577
|
// Attach enterprise metadata so the frontend knows which fields to lock
|
|
382
578
|
if (effective.enterprise) {
|
|
383
579
|
response._enterprise = {
|
|
@@ -420,7 +616,8 @@ async function handlePostConfig(req, res) {
|
|
|
420
616
|
if (effective.lockedFields.has('audit.log_dir') && incoming.audit?.log_dir !== orig.audit.log_dir) {
|
|
421
617
|
violations.push('audit.log_dir');
|
|
422
618
|
}
|
|
423
|
-
|
|
619
|
+
const incomingSlurmEnabled = incoming.slurm?.enabled ?? orig.slurm.enabled;
|
|
620
|
+
if (effective.lockedFields.has('slurm.enabled') && incomingSlurmEnabled !== orig.slurm.enabled) {
|
|
424
621
|
violations.push('slurm.enabled');
|
|
425
622
|
}
|
|
426
623
|
if (violations.length > 0) {
|
|
@@ -1551,6 +1748,16 @@ function handleGetSecurity(_req, res) {
|
|
|
1551
1748
|
},
|
|
1552
1749
|
});
|
|
1553
1750
|
}
|
|
1751
|
+
function handleGetClaudeAuthStatus(_req, res) {
|
|
1752
|
+
const auth = readClaudeCredentialSnapshot();
|
|
1753
|
+
json(res, {
|
|
1754
|
+
ok: true,
|
|
1755
|
+
provider: 'claude',
|
|
1756
|
+
loggedIn: auth.loggedIn,
|
|
1757
|
+
email: auth.email,
|
|
1758
|
+
checkedAt: new Date().toISOString(),
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1554
1761
|
async function handleStopSession(req, res) {
|
|
1555
1762
|
try {
|
|
1556
1763
|
const body = await readBody(req);
|
|
@@ -1758,7 +1965,12 @@ async function handlePostWebTerminalStart(req, res) {
|
|
|
1758
1965
|
}
|
|
1759
1966
|
async function handleGetWebTerminalSessions(res) {
|
|
1760
1967
|
const records = (0, web_terminal_js_1.listWebTerminalRecords)();
|
|
1968
|
+
const localNode = (0, os_1.hostname)();
|
|
1761
1969
|
for (const record of records) {
|
|
1970
|
+
// Shared home directories may contain records from other nodes.
|
|
1971
|
+
// Only mutate runtime status for sessions that belong to this node.
|
|
1972
|
+
if (record.node !== localNode)
|
|
1973
|
+
continue;
|
|
1762
1974
|
const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
|
|
1763
1975
|
if (alive && record.status !== 'running') {
|
|
1764
1976
|
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, { status: 'running', exitCode: null, error: null });
|
|
@@ -1791,6 +2003,10 @@ async function handlePostWebTerminalStop(req, res) {
|
|
|
1791
2003
|
json(res, { ok: false, error: 'Terminal session not found' }, 404);
|
|
1792
2004
|
return;
|
|
1793
2005
|
}
|
|
2006
|
+
if (record.node !== (0, os_1.hostname)()) {
|
|
2007
|
+
json(res, { ok: false, error: `Terminal session is on a different node (${record.node})` }, 400);
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
1794
2010
|
try {
|
|
1795
2011
|
await (0, web_terminal_js_1.killTmuxSession)(record.tmuxSession);
|
|
1796
2012
|
}
|
|
@@ -1845,6 +2061,43 @@ async function handleValidatePath(req, res) {
|
|
|
1845
2061
|
json(res, { valid: false, error: err.message ?? String(err) }, 400);
|
|
1846
2062
|
}
|
|
1847
2063
|
}
|
|
2064
|
+
// ── Directory browse helper ─────────────────────────────
|
|
2065
|
+
async function handleBrowseDir(req, res) {
|
|
2066
|
+
try {
|
|
2067
|
+
const body = await readBody(req);
|
|
2068
|
+
const { path: rawPath } = JSON.parse(body);
|
|
2069
|
+
const resolved = (rawPath && typeof rawPath === 'string' ? rawPath : '~').replace(/^~/, (0, os_1.homedir)());
|
|
2070
|
+
if (!(0, fs_1.existsSync)(resolved) || !(0, fs_1.statSync)(resolved).isDirectory()) {
|
|
2071
|
+
json(res, { ok: false, error: 'Not a directory', path: resolved });
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
let entries;
|
|
2075
|
+
try {
|
|
2076
|
+
entries = (0, fs_1.readdirSync)(resolved);
|
|
2077
|
+
}
|
|
2078
|
+
catch {
|
|
2079
|
+
json(res, { ok: false, error: 'Cannot read directory', path: resolved });
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
const dirs = [];
|
|
2083
|
+
for (const entry of entries) {
|
|
2084
|
+
if (entry.startsWith('.'))
|
|
2085
|
+
continue; // skip dotfiles
|
|
2086
|
+
const full = (0, path_1.join)(resolved, entry);
|
|
2087
|
+
try {
|
|
2088
|
+
if ((0, fs_1.statSync)(full).isDirectory()) {
|
|
2089
|
+
dirs.push({ name: entry, path: full });
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
catch { /* skip inaccessible */ }
|
|
2093
|
+
}
|
|
2094
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
2095
|
+
json(res, { ok: true, path: resolved, dirs });
|
|
2096
|
+
}
|
|
2097
|
+
catch (err) {
|
|
2098
|
+
json(res, { ok: false, error: err.message ?? String(err) }, 400);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
1848
2101
|
// ── Dataset stats helpers ───────────────────────────────
|
|
1849
2102
|
function formatBytesUI(bytes) {
|
|
1850
2103
|
if (bytes === 0)
|
|
@@ -1968,6 +2221,129 @@ async function handlePostDatasetScan(req, res) {
|
|
|
1968
2221
|
json(res, { ok: false, error: err.message ?? String(err) }, 500);
|
|
1969
2222
|
}
|
|
1970
2223
|
}
|
|
2224
|
+
async function handlePostDatasetExampleInstall(req, res) {
|
|
2225
|
+
try {
|
|
2226
|
+
const body = await readBody(req);
|
|
2227
|
+
let parsed = {};
|
|
2228
|
+
if (body.trim()) {
|
|
2229
|
+
try {
|
|
2230
|
+
parsed = JSON.parse(body);
|
|
2231
|
+
}
|
|
2232
|
+
catch {
|
|
2233
|
+
json(res, { ok: false, error: 'Invalid JSON body' }, 400);
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
const requestedNameRaw = typeof parsed.name === 'string' ? parsed.name.trim() : '';
|
|
2238
|
+
const datasetName = requestedNameRaw || IRIS_SAMPLE_DATASET_NAME;
|
|
2239
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(datasetName)) {
|
|
2240
|
+
json(res, {
|
|
2241
|
+
ok: false,
|
|
2242
|
+
error: 'Invalid dataset name. Use alphanumerics, hyphens, dots, underscores.',
|
|
2243
|
+
}, 400);
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
const datasetMode = parsed.mode === 'rw' ? 'rw' : 'ro';
|
|
2247
|
+
const sourceUrl = resolveIrisSampleSourceUrl();
|
|
2248
|
+
const configPath = (0, config_js_1.getConfigPath)();
|
|
2249
|
+
let obj = {};
|
|
2250
|
+
if ((0, fs_1.existsSync)(configPath)) {
|
|
2251
|
+
const rawText = (0, fs_1.readFileSync)(configPath, 'utf-8');
|
|
2252
|
+
const stripped = rawText
|
|
2253
|
+
.split('\n')
|
|
2254
|
+
.filter((line) => !line.trimStart().startsWith('//'))
|
|
2255
|
+
.join('\n');
|
|
2256
|
+
try {
|
|
2257
|
+
obj = JSON.parse(stripped);
|
|
2258
|
+
}
|
|
2259
|
+
catch {
|
|
2260
|
+
obj = {};
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
const datasets = Array.isArray(obj.datasets) ? obj.datasets : [];
|
|
2264
|
+
const byNameConflict = datasets.find((d) => {
|
|
2265
|
+
const n = typeof d.name === 'string' ? d.name : '';
|
|
2266
|
+
return n.toLowerCase() === datasetName.toLowerCase();
|
|
2267
|
+
});
|
|
2268
|
+
if (byNameConflict) {
|
|
2269
|
+
json(res, { ok: false, error: `Dataset "${datasetName}" is already registered.` }, 409);
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
const datasetDir = (0, path_1.join)(config_js_1.LABGATE_DIR, 'datasets', datasetName);
|
|
2273
|
+
const byPathConflict = datasets.find((d) => {
|
|
2274
|
+
if (typeof d.path !== 'string')
|
|
2275
|
+
return false;
|
|
2276
|
+
return (0, path_1.resolve)(d.path.replace(/^~/, (0, os_1.homedir)())) === (0, path_1.resolve)(datasetDir);
|
|
2277
|
+
});
|
|
2278
|
+
if (byPathConflict) {
|
|
2279
|
+
json(res, { ok: false, error: `Dataset path is already registered: ${datasetDir}` }, 409);
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
const ctrl = new AbortController();
|
|
2283
|
+
const timeout = setTimeout(() => ctrl.abort(), 20_000);
|
|
2284
|
+
let csvText = '';
|
|
2285
|
+
try {
|
|
2286
|
+
const download = await fetch(sourceUrl, { signal: ctrl.signal });
|
|
2287
|
+
if (!download.ok) {
|
|
2288
|
+
json(res, { ok: false, error: `Failed to download sample dataset (HTTP ${download.status}).` }, 502);
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
csvText = await download.text();
|
|
2292
|
+
}
|
|
2293
|
+
finally {
|
|
2294
|
+
clearTimeout(timeout);
|
|
2295
|
+
}
|
|
2296
|
+
if (!csvText.trim()) {
|
|
2297
|
+
json(res, { ok: false, error: 'Downloaded sample dataset is empty.' }, 502);
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
if (csvText.length > 5_000_000) {
|
|
2301
|
+
json(res, { ok: false, error: 'Downloaded sample dataset is unexpectedly large.' }, 502);
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
(0, fs_1.mkdirSync)(datasetDir, { recursive: true, mode: config_js_1.PRIVATE_DIR_MODE });
|
|
2305
|
+
const csvPath = (0, path_1.join)(datasetDir, 'iris.csv');
|
|
2306
|
+
const readmePath = (0, path_1.join)(datasetDir, 'README.md');
|
|
2307
|
+
(0, fs_1.writeFileSync)(csvPath, csvText, { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
|
|
2308
|
+
(0, fs_1.writeFileSync)(readmePath, IRIS_SAMPLE_README, { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
|
|
2309
|
+
const totalSize = (0, fs_1.statSync)(csvPath).size + (0, fs_1.statSync)(readmePath).size;
|
|
2310
|
+
const lineCount = csvText
|
|
2311
|
+
.split('\n')
|
|
2312
|
+
.map((line) => line.trim())
|
|
2313
|
+
.filter((line) => line.length > 0)
|
|
2314
|
+
.length;
|
|
2315
|
+
const rowCount = Math.max(0, lineCount - 1);
|
|
2316
|
+
const entry = {
|
|
2317
|
+
path: datasetDir,
|
|
2318
|
+
name: datasetName,
|
|
2319
|
+
mode: datasetMode,
|
|
2320
|
+
description: `Iris flower measurements sample dataset (${rowCount} rows).`,
|
|
2321
|
+
stats: {
|
|
2322
|
+
file_count: 2,
|
|
2323
|
+
total_size: totalSize,
|
|
2324
|
+
total_size_formatted: formatBytesUI(totalSize),
|
|
2325
|
+
scanned_at: new Date().toISOString(),
|
|
2326
|
+
},
|
|
2327
|
+
};
|
|
2328
|
+
datasets.push(entry);
|
|
2329
|
+
obj.datasets = datasets;
|
|
2330
|
+
(0, fs_1.writeFileSync)(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
|
|
2331
|
+
(0, config_js_1.ensurePrivateFile)(configPath);
|
|
2332
|
+
json(res, {
|
|
2333
|
+
ok: true,
|
|
2334
|
+
dataset: entry,
|
|
2335
|
+
downloaded_from: sourceUrl,
|
|
2336
|
+
files: ['iris.csv', 'README.md'],
|
|
2337
|
+
}, 201);
|
|
2338
|
+
}
|
|
2339
|
+
catch (err) {
|
|
2340
|
+
if (err?.name === 'AbortError') {
|
|
2341
|
+
json(res, { ok: false, error: 'Timed out downloading sample dataset.' }, 504);
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
1971
2347
|
function handleGetLogs(_req, res) {
|
|
1972
2348
|
const config = (0, config_js_1.loadConfig)();
|
|
1973
2349
|
if (!config.audit.enabled) {
|
|
@@ -3288,12 +3664,18 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3288
3664
|
else if (pathname === '/api/validate-path' && method === 'POST') {
|
|
3289
3665
|
await handleValidatePath(req, res);
|
|
3290
3666
|
}
|
|
3667
|
+
else if (pathname === '/api/browse-dir' && method === 'POST') {
|
|
3668
|
+
await handleBrowseDir(req, res);
|
|
3669
|
+
}
|
|
3291
3670
|
else if (pathname === '/api/dataset-stats' && method === 'GET') {
|
|
3292
3671
|
handleGetDatasetStats(req, res);
|
|
3293
3672
|
}
|
|
3294
3673
|
else if (pathname === '/api/dataset-scan' && method === 'POST') {
|
|
3295
3674
|
await handlePostDatasetScan(req, res);
|
|
3296
3675
|
}
|
|
3676
|
+
else if (pathname === '/api/dataset-example/install' && method === 'POST') {
|
|
3677
|
+
await handlePostDatasetExampleInstall(req, res);
|
|
3678
|
+
}
|
|
3297
3679
|
else if (/^\/api\/sessions\/[^/]+\/instructions$/.test(pathname) && method === 'GET') {
|
|
3298
3680
|
handleGetSessionInstructions(pathname, reqUrl, res);
|
|
3299
3681
|
}
|
|
@@ -3306,6 +3688,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3306
3688
|
else if (pathname === '/api/security' && method === 'GET') {
|
|
3307
3689
|
handleGetSecurity(req, res);
|
|
3308
3690
|
}
|
|
3691
|
+
else if (pathname === '/api/claude/auth' && method === 'GET') {
|
|
3692
|
+
handleGetClaudeAuthStatus(req, res);
|
|
3693
|
+
}
|
|
3309
3694
|
else if (pathname === '/api/results' && method === 'GET') {
|
|
3310
3695
|
handleGetResults(reqUrl, res);
|
|
3311
3696
|
}
|
|
@@ -3395,6 +3780,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3395
3780
|
else if (pathname === '/vendor/xterm/addon-fit.js' && method === 'GET') {
|
|
3396
3781
|
serveVendorAsset(res, XTERM_FIT_JS_PATH, 'application/javascript; charset=utf-8');
|
|
3397
3782
|
}
|
|
3783
|
+
else if (pathname === '/vendor/xterm/addon-web-links.js' && method === 'GET') {
|
|
3784
|
+
serveVendorAsset(res, XTERM_WEB_LINKS_JS_PATH, 'application/javascript; charset=utf-8');
|
|
3785
|
+
}
|
|
3398
3786
|
else {
|
|
3399
3787
|
if (pathname.startsWith('/api/')) {
|
|
3400
3788
|
json(res, { ok: false, error: 'Not found' }, 404);
|
|
@@ -3472,13 +3860,16 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3472
3860
|
if (useTcp) {
|
|
3473
3861
|
const actualPort = server.address()?.port ?? listenPort;
|
|
3474
3862
|
dashboardQuickLink = `http://localhost:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
|
|
3475
|
-
log.step(`Settings: ${dashboardQuickLink}`);
|
|
3863
|
+
log.step(`Settings: ${formatTerminalHyperlink(dashboardQuickLink)}`);
|
|
3476
3864
|
try {
|
|
3477
3865
|
writeDashboardLink(dashboardQuickLink);
|
|
3478
3866
|
}
|
|
3479
3867
|
catch {
|
|
3480
3868
|
// Best effort: statusline can still fall back to LABGATE_DASHBOARD_URL/default URL.
|
|
3481
3869
|
}
|
|
3870
|
+
if (shouldAutoOpenUiBrowser(standalone)) {
|
|
3871
|
+
autoOpenUiBrowser(dashboardQuickLink);
|
|
3872
|
+
}
|
|
3482
3873
|
}
|
|
3483
3874
|
else {
|
|
3484
3875
|
try {
|