kroki 1.0.4 → 1.0.7
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/bin/kroki.js +1381 -116
- package/package.json +1 -1
package/bin/kroki.js
CHANGED
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import readline, { createInterface } from 'node:readline';
|
|
15
|
+
import { exec } from 'node:child_process';
|
|
13
16
|
|
|
14
17
|
const CONFIG_DIR = join(homedir(), '.kroki');
|
|
18
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
15
19
|
const SESSION_FILE = join(CONFIG_DIR, 'session.json');
|
|
16
20
|
|
|
17
21
|
const DEFAULT_SUPABASE_URL = 'https://vjkzlvtvnmorokocnbhp.supabase.co';
|
|
@@ -24,43 +28,336 @@ function ensureConfigDir() {
|
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
function openBrowser(url) {
|
|
32
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
33
|
+
exec(`${cmd} "${url}"`, () => {});
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
function decodePairingCode(code) {
|
|
28
|
-
|
|
37
|
+
if (typeof code === 'object' && code !== null) {
|
|
38
|
+
if (code.token) {
|
|
39
|
+
if (!code.googleId) {
|
|
40
|
+
try {
|
|
41
|
+
const parts = code.token.split('.');
|
|
42
|
+
if (parts.length >= 2) {
|
|
43
|
+
const jwtPayload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
44
|
+
code.googleId = jwtPayload.google_id || jwtPayload.sub;
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
return code;
|
|
49
|
+
}
|
|
50
|
+
throw new Error('Missing token in authorization payload');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const trimmed = String(code).trim();
|
|
29
54
|
const raw = trimmed.startsWith('kroki_') ? trimmed.slice(6) : trimmed;
|
|
30
55
|
try {
|
|
31
56
|
const jsonStr = Buffer.from(raw, 'base64url').toString('utf8');
|
|
32
57
|
const parsed = JSON.parse(jsonStr);
|
|
33
|
-
if (!parsed.
|
|
34
|
-
throw new Error('Missing
|
|
58
|
+
if (!parsed.token) {
|
|
59
|
+
throw new Error('Missing token in code');
|
|
60
|
+
}
|
|
61
|
+
if (!parsed.googleId) {
|
|
62
|
+
try {
|
|
63
|
+
const parts = parsed.token.split('.');
|
|
64
|
+
if (parts.length >= 2) {
|
|
65
|
+
const jwtPayload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
66
|
+
parsed.googleId = jwtPayload.google_id || jwtPayload.sub;
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
35
69
|
}
|
|
36
70
|
return parsed;
|
|
37
71
|
} catch {
|
|
38
72
|
throw new Error(
|
|
39
|
-
'Invalid Kroki
|
|
73
|
+
'Invalid Kroki code.\nPlease get a valid code from your Kroki Devices panel: https://kroki.ai/#/devices',
|
|
40
74
|
);
|
|
41
75
|
}
|
|
42
76
|
}
|
|
43
77
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
78
|
+
// ── Config & Session Management ─────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function getConfig() {
|
|
81
|
+
if (existsSync(CONFIG_FILE)) {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
84
|
+
} catch {}
|
|
50
85
|
}
|
|
86
|
+
// Fallback / migration from legacy session.json
|
|
87
|
+
if (existsSync(SESSION_FILE)) {
|
|
88
|
+
try {
|
|
89
|
+
const legacy = JSON.parse(readFileSync(SESSION_FILE, 'utf8'));
|
|
90
|
+
if (legacy && legacy.token) {
|
|
91
|
+
const config = {
|
|
92
|
+
account: {
|
|
93
|
+
googleId: legacy.googleId,
|
|
94
|
+
token: legacy.token,
|
|
95
|
+
supabaseUrl: legacy.supabaseUrl || DEFAULT_SUPABASE_URL,
|
|
96
|
+
anonKey: legacy.anonKey || DEFAULT_ANON_KEY,
|
|
97
|
+
},
|
|
98
|
+
selectedDeviceIds: legacy.deviceId ? [legacy.deviceId] : [],
|
|
99
|
+
defaultDeviceId: legacy.deviceId || null,
|
|
100
|
+
devices: {},
|
|
101
|
+
};
|
|
102
|
+
if (legacy.deviceId) {
|
|
103
|
+
config.devices[legacy.deviceId] = {
|
|
104
|
+
deviceId: legacy.deviceId,
|
|
105
|
+
deviceName: legacy.deviceName || legacy.deviceId,
|
|
106
|
+
platform: legacy.platform || 'mac',
|
|
107
|
+
lastSeenAt: legacy.pairedAt || Date.now(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
saveConfig(config);
|
|
111
|
+
return config;
|
|
112
|
+
}
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
51
116
|
}
|
|
52
117
|
|
|
53
|
-
function
|
|
118
|
+
function saveConfig(config) {
|
|
54
119
|
ensureConfigDir();
|
|
55
|
-
writeFileSync(
|
|
120
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
121
|
+
// Also keep session.json in sync for legacy compatibility
|
|
122
|
+
if (config.account && config.account.token) {
|
|
123
|
+
const activeDevice = (config.devices && config.defaultDeviceId && config.devices[config.defaultDeviceId]) || null;
|
|
124
|
+
const legacySession = {
|
|
125
|
+
v: 1,
|
|
126
|
+
token: config.account.token,
|
|
127
|
+
googleId: config.account.googleId,
|
|
128
|
+
deviceId: activeDevice?.deviceId || '',
|
|
129
|
+
deviceName: activeDevice?.deviceName || '',
|
|
130
|
+
platform: activeDevice?.platform || 'mac',
|
|
131
|
+
supabaseUrl: config.account.supabaseUrl || DEFAULT_SUPABASE_URL,
|
|
132
|
+
pairedAt: Date.now(),
|
|
133
|
+
};
|
|
134
|
+
writeFileSync(SESSION_FILE, JSON.stringify(legacySession, null, 2), { mode: 0o600 });
|
|
135
|
+
}
|
|
56
136
|
}
|
|
57
137
|
|
|
58
|
-
function
|
|
59
|
-
if (existsSync(
|
|
60
|
-
|
|
138
|
+
function clearAllSessions() {
|
|
139
|
+
if (existsSync(CONFIG_FILE)) unlinkSync(CONFIG_FILE);
|
|
140
|
+
if (existsSync(SESSION_FILE)) unlinkSync(SESSION_FILE);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function saveFromCode(code) {
|
|
144
|
+
const payload = decodePairingCode(code);
|
|
145
|
+
const config = getConfig() || {
|
|
146
|
+
account: {},
|
|
147
|
+
selectedDeviceIds: [],
|
|
148
|
+
defaultDeviceId: null,
|
|
149
|
+
devices: {},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
config.account = {
|
|
153
|
+
googleId: payload.googleId,
|
|
154
|
+
email: payload.email || config.account?.email,
|
|
155
|
+
token: payload.token,
|
|
156
|
+
supabaseUrl: payload.supabaseUrl || DEFAULT_SUPABASE_URL,
|
|
157
|
+
anonKey: payload.anonKey || DEFAULT_ANON_KEY,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
config.devices = config.devices || {};
|
|
161
|
+
config.selectedDeviceIds = config.selectedDeviceIds || [];
|
|
162
|
+
|
|
163
|
+
if (Array.isArray(payload.accounts)) {
|
|
164
|
+
for (const acc of payload.accounts) {
|
|
165
|
+
if (Array.isArray(acc.devices)) {
|
|
166
|
+
for (const d of acc.devices) {
|
|
167
|
+
const resolvedEmail =
|
|
168
|
+
acc.email ||
|
|
169
|
+
(acc.google_id === payload.googleId ? payload.email : null) ||
|
|
170
|
+
config.account?.email ||
|
|
171
|
+
acc.label ||
|
|
172
|
+
acc.google_id;
|
|
173
|
+
config.devices[d.device_id] = {
|
|
174
|
+
deviceId: d.device_id,
|
|
175
|
+
deviceName: d.device_name || d.device_id,
|
|
176
|
+
platform: d.platform || 'mac',
|
|
177
|
+
account: resolvedEmail,
|
|
178
|
+
googleId: acc.google_id,
|
|
179
|
+
lastSeenAt: d.last_seen_at ? Date.parse(d.last_seen_at) : Date.now(),
|
|
180
|
+
};
|
|
181
|
+
if (!config.defaultDeviceId) {
|
|
182
|
+
config.defaultDeviceId = d.device_id;
|
|
183
|
+
}
|
|
184
|
+
if (!config.selectedDeviceIds.includes(d.device_id)) {
|
|
185
|
+
config.selectedDeviceIds.push(d.device_id);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (payload.deviceId) {
|
|
193
|
+
config.devices[payload.deviceId] = {
|
|
194
|
+
deviceId: payload.deviceId,
|
|
195
|
+
deviceName: payload.deviceName || payload.deviceId,
|
|
196
|
+
platform: payload.platform || 'mac',
|
|
197
|
+
lastSeenAt: Date.now(),
|
|
198
|
+
};
|
|
199
|
+
if (!config.selectedDeviceIds.includes(payload.deviceId)) {
|
|
200
|
+
config.selectedDeviceIds.push(payload.deviceId);
|
|
201
|
+
}
|
|
202
|
+
config.defaultDeviceId = payload.deviceId;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
saveConfig(config);
|
|
206
|
+
return config;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveTargetDevice(config, targetQuery) {
|
|
210
|
+
const devices = config.devices || {};
|
|
211
|
+
const allDevices = Object.values(devices);
|
|
212
|
+
|
|
213
|
+
const query =
|
|
214
|
+
targetQuery !== null && targetQuery !== undefined && String(targetQuery).trim() !== ''
|
|
215
|
+
? targetQuery
|
|
216
|
+
: process.env.KROKI_DEVICE && String(process.env.KROKI_DEVICE).trim() !== ''
|
|
217
|
+
? process.env.KROKI_DEVICE
|
|
218
|
+
: null;
|
|
219
|
+
|
|
220
|
+
if (query !== null && query !== undefined && String(query).trim() !== '') {
|
|
221
|
+
const q = String(query).toLowerCase().trim();
|
|
222
|
+
// 1. Index number (1-based, e.g. "1", "2")
|
|
223
|
+
const num = parseInt(q, 10);
|
|
224
|
+
if (!isNaN(num) && String(num) === q && num >= 1 && num <= allDevices.length) {
|
|
225
|
+
return allDevices[num - 1];
|
|
226
|
+
}
|
|
227
|
+
// 2. Exact ID
|
|
228
|
+
let match = allDevices.find(d => d.deviceId.toLowerCase() === q);
|
|
229
|
+
// 3. Exact name
|
|
230
|
+
if (!match) match = allDevices.find(d => (d.deviceName || '').toLowerCase() === q);
|
|
231
|
+
// 4. Exact account / email
|
|
232
|
+
if (!match) match = allDevices.find(d => (d.account || '').toLowerCase() === q);
|
|
233
|
+
// 5. Substring name or account
|
|
234
|
+
if (!match) {
|
|
235
|
+
match = allDevices.find(
|
|
236
|
+
d => (d.deviceName || '').toLowerCase().includes(q) || (d.account || '').toLowerCase().includes(q),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (!match) {
|
|
240
|
+
const names = allDevices
|
|
241
|
+
.map((d, i) => `[${i + 1}] "${d.deviceName || d.deviceId}"${d.account ? ` (${d.account})` : ''}`)
|
|
242
|
+
.join('\n ');
|
|
243
|
+
throw new Error(`Device "${targetQuery}" not found.\nAvailable browsers:\n ${names || 'none'}`);
|
|
244
|
+
}
|
|
245
|
+
return match;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Default: defaultDeviceId or first device
|
|
249
|
+
const defId = config.defaultDeviceId;
|
|
250
|
+
if (defId && devices[defId]) {
|
|
251
|
+
return devices[defId];
|
|
61
252
|
}
|
|
253
|
+
if (allDevices.length > 0) {
|
|
254
|
+
return allDevices[0];
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isTokenExpired(token) {
|
|
260
|
+
if (!token) return true;
|
|
261
|
+
try {
|
|
262
|
+
const parts = token.split('.');
|
|
263
|
+
if (parts.length >= 2) {
|
|
264
|
+
const jwt = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
265
|
+
if (jwt.exp) {
|
|
266
|
+
return Date.now() / 1000 >= jwt.exp;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch {}
|
|
270
|
+
return false;
|
|
62
271
|
}
|
|
63
272
|
|
|
273
|
+
async function fetchOnlineDeviceIds(config, timeoutMs = 1500) {
|
|
274
|
+
if (!config?.account?.token || !config?.account?.supabaseUrl) {
|
|
275
|
+
return { onlineIds: new Set(), expired: false };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (isTokenExpired(config.account.token)) {
|
|
279
|
+
return { onlineIds: new Set(), expired: true };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const onlineIds = new Set();
|
|
283
|
+
const googleIds = new Set();
|
|
284
|
+
if (config.account.googleId) googleIds.add(config.account.googleId);
|
|
285
|
+
for (const d of Object.values(config.devices || {})) {
|
|
286
|
+
if (d.googleId) googleIds.add(d.googleId);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const anonKey = config.account.anonKey || DEFAULT_ANON_KEY;
|
|
290
|
+
const wsBase = (config.account.supabaseUrl || DEFAULT_SUPABASE_URL).replace(/^http/, 'ws');
|
|
291
|
+
const wsUrl = `${wsBase}/realtime/v1/websocket?apikey=${anonKey}&vsn=1.0.0`;
|
|
292
|
+
|
|
293
|
+
await Promise.all(
|
|
294
|
+
Array.from(googleIds).map(gId => {
|
|
295
|
+
return new Promise(resolve => {
|
|
296
|
+
let ws = null;
|
|
297
|
+
let settled = false;
|
|
298
|
+
const done = () => {
|
|
299
|
+
if (!settled) {
|
|
300
|
+
settled = true;
|
|
301
|
+
if (ws) {
|
|
302
|
+
try {
|
|
303
|
+
ws.close();
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
resolve();
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const timer = setTimeout(done, timeoutMs);
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
ws = new globalThis.WebSocket(wsUrl);
|
|
314
|
+
ws.onopen = () => {
|
|
315
|
+
const payload = {
|
|
316
|
+
topic: `realtime:control:${gId}`,
|
|
317
|
+
event: 'phx_join',
|
|
318
|
+
payload: {
|
|
319
|
+
config: { private: true, broadcast: { self: false }, presence: { key: '' } },
|
|
320
|
+
access_token: config.account.token,
|
|
321
|
+
},
|
|
322
|
+
ref: 'ping_' + gId,
|
|
323
|
+
};
|
|
324
|
+
ws.send(JSON.stringify(payload));
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
ws.onmessage = event => {
|
|
328
|
+
try {
|
|
329
|
+
const msg = JSON.parse(event.data);
|
|
330
|
+
if (msg.event === 'presence_state' && msg.payload) {
|
|
331
|
+
for (const [k, v] of Object.entries(msg.payload)) {
|
|
332
|
+
if (k && k.startsWith('device-')) onlineIds.add(k);
|
|
333
|
+
const metas = Array.isArray(v) ? v : Array.isArray(v?.metas) ? v.metas : [];
|
|
334
|
+
for (const m of metas) {
|
|
335
|
+
if (m && m.deviceId) onlineIds.add(m.deviceId);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
done();
|
|
340
|
+
}
|
|
341
|
+
} catch {}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
ws.onerror = () => {
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
done();
|
|
347
|
+
};
|
|
348
|
+
} catch {
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
done();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
return { onlineIds, expired: false };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Realtime WebSocket Connection ───────────────────────────────────────────
|
|
360
|
+
|
|
64
361
|
class RealtimeConnection {
|
|
65
362
|
constructor(config) {
|
|
66
363
|
this.url = config.supabaseUrl || DEFAULT_SUPABASE_URL;
|
|
@@ -73,12 +370,17 @@ class RealtimeConnection {
|
|
|
73
370
|
this.joinRef = null;
|
|
74
371
|
this.ws = null;
|
|
75
372
|
this.heartbeatTimer = null;
|
|
373
|
+
this.onlineDeviceIds = new Set();
|
|
76
374
|
}
|
|
77
375
|
|
|
78
376
|
nextRef() {
|
|
79
377
|
return String(++this.ref);
|
|
80
378
|
}
|
|
81
379
|
|
|
380
|
+
isDeviceOnline(deviceId) {
|
|
381
|
+
return this.onlineDeviceIds.has(deviceId);
|
|
382
|
+
}
|
|
383
|
+
|
|
82
384
|
async connect(timeoutMs = 15000) {
|
|
83
385
|
return new Promise((resolve, reject) => {
|
|
84
386
|
const wsUrl = `${this.url.replace(/^http/, 'ws')}/realtime/v1/websocket?apikey=${this.anonKey}&vsn=1.0.0`;
|
|
@@ -100,7 +402,7 @@ class RealtimeConnection {
|
|
|
100
402
|
);
|
|
101
403
|
}
|
|
102
404
|
}, 25000);
|
|
103
|
-
resolve(this);
|
|
405
|
+
setTimeout(() => resolve(this), 250);
|
|
104
406
|
})
|
|
105
407
|
.catch(reject);
|
|
106
408
|
};
|
|
@@ -119,12 +421,47 @@ class RealtimeConnection {
|
|
|
119
421
|
topic: this.topic,
|
|
120
422
|
event: 'phx_join',
|
|
121
423
|
payload: {
|
|
122
|
-
config: { private: true, broadcast: { self: false } },
|
|
424
|
+
config: { private: true, broadcast: { self: false }, presence: { key: '' } },
|
|
123
425
|
access_token: this.token,
|
|
124
426
|
},
|
|
125
427
|
ref: this.joinRef,
|
|
126
428
|
};
|
|
127
429
|
|
|
430
|
+
const presenceHandler = event => {
|
|
431
|
+
try {
|
|
432
|
+
const msg = JSON.parse(event.data);
|
|
433
|
+
if (msg.event === 'presence_state' && msg.payload) {
|
|
434
|
+
for (const [k, v] of Object.entries(msg.payload)) {
|
|
435
|
+
if (k && k.startsWith('device-')) this.onlineDeviceIds.add(k);
|
|
436
|
+
const metas = Array.isArray(v) ? v : Array.isArray(v?.metas) ? v.metas : [];
|
|
437
|
+
for (const m of metas) {
|
|
438
|
+
if (m && m.deviceId) this.onlineDeviceIds.add(m.deviceId);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
} else if (msg.event === 'presence_diff' && msg.payload) {
|
|
442
|
+
if (msg.payload.joins) {
|
|
443
|
+
for (const [k, v] of Object.entries(msg.payload.joins)) {
|
|
444
|
+
if (k && k.startsWith('device-')) this.onlineDeviceIds.add(k);
|
|
445
|
+
const metas = Array.isArray(v) ? v : Array.isArray(v?.metas) ? v.metas : [];
|
|
446
|
+
for (const m of metas) {
|
|
447
|
+
if (m && m.deviceId) this.onlineDeviceIds.add(m.deviceId);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (msg.payload.leaves) {
|
|
452
|
+
for (const [k, v] of Object.entries(msg.payload.leaves)) {
|
|
453
|
+
if (k && k.startsWith('device-')) this.onlineDeviceIds.delete(k);
|
|
454
|
+
const metas = Array.isArray(v) ? v : Array.isArray(v?.metas) ? v.metas : [];
|
|
455
|
+
for (const m of metas) {
|
|
456
|
+
if (m && m.deviceId) this.onlineDeviceIds.delete(m.deviceId);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} catch {}
|
|
462
|
+
};
|
|
463
|
+
this.ws.addEventListener('message', presenceHandler);
|
|
464
|
+
|
|
128
465
|
const handler = event => {
|
|
129
466
|
try {
|
|
130
467
|
const msg = JSON.parse(event.data);
|
|
@@ -133,7 +470,12 @@ class RealtimeConnection {
|
|
|
133
470
|
if (msg.payload?.status === 'ok') {
|
|
134
471
|
resolve();
|
|
135
472
|
} else {
|
|
136
|
-
|
|
473
|
+
const reason = msg.payload?.response?.reason || JSON.stringify(msg.payload || {});
|
|
474
|
+
if (typeof reason === 'string' && reason.includes('Token has expired')) {
|
|
475
|
+
reject(new Error('Session token has expired. Run "kroki login" to reconnect.'));
|
|
476
|
+
} else {
|
|
477
|
+
reject(new Error(`Channel join rejected: ${reason}`));
|
|
478
|
+
}
|
|
137
479
|
}
|
|
138
480
|
}
|
|
139
481
|
} catch {}
|
|
@@ -144,38 +486,70 @@ class RealtimeConnection {
|
|
|
144
486
|
});
|
|
145
487
|
}
|
|
146
488
|
|
|
147
|
-
sendCommand(command, onEvent, timeoutMs =
|
|
489
|
+
async sendCommand(command, onEvent, timeoutMs = 600000) {
|
|
148
490
|
return new Promise((resolve, reject) => {
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
491
|
+
let activityTimeout = null;
|
|
492
|
+
const resetActivityTimeout = () => {
|
|
493
|
+
if (activityTimeout) clearTimeout(activityTimeout);
|
|
494
|
+
// 2-minute silence limit between steps
|
|
495
|
+
activityTimeout = setTimeout(() => {
|
|
496
|
+
cleanup();
|
|
497
|
+
reject(new Error('Task timed out: no activity received for 2 minutes'));
|
|
498
|
+
}, 120000);
|
|
499
|
+
};
|
|
500
|
+
resetActivityTimeout();
|
|
501
|
+
|
|
502
|
+
const totalTimeout = setTimeout(() => {
|
|
503
|
+
cleanup();
|
|
504
|
+
reject(new Error(`Task reached maximum time limit (${Math.round(timeoutMs / 60000)}m)`));
|
|
152
505
|
}, timeoutMs);
|
|
153
506
|
|
|
507
|
+
const cleanup = () => {
|
|
508
|
+
if (activityTimeout) clearTimeout(activityTimeout);
|
|
509
|
+
clearTimeout(totalTimeout);
|
|
510
|
+
this.ws.removeEventListener('message', handler);
|
|
511
|
+
};
|
|
512
|
+
|
|
154
513
|
const handler = event => {
|
|
155
514
|
try {
|
|
156
515
|
const msg = JSON.parse(event.data);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
516
|
+
let envelope = null;
|
|
517
|
+
if (msg.event === 'broadcast' && msg.payload) {
|
|
518
|
+
envelope = msg.payload.payload || msg.payload;
|
|
519
|
+
} else if (msg.event === 'event' && msg.payload) {
|
|
520
|
+
envelope = msg.payload;
|
|
521
|
+
} else if (msg.payload && (msg.payload.kind || msg.payload.taskId)) {
|
|
522
|
+
envelope = msg.payload;
|
|
523
|
+
}
|
|
160
524
|
|
|
525
|
+
if (envelope && (envelope.taskId === command.taskId || !envelope.taskId)) {
|
|
526
|
+
resetActivityTimeout();
|
|
161
527
|
if (onEvent) onEvent(envelope);
|
|
162
528
|
|
|
163
529
|
if (
|
|
530
|
+
envelope.kind === 'task_complete' ||
|
|
164
531
|
envelope.kind === 'chat_task_complete' ||
|
|
165
532
|
envelope.kind === 'workflow_complete' ||
|
|
166
|
-
envelope.
|
|
533
|
+
envelope.payload?.state === 'task.ok' ||
|
|
534
|
+
envelope.payload?.state === 'task.cancel'
|
|
167
535
|
) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
536
|
+
cleanup();
|
|
537
|
+
if (envelope.kind === 'workflow_complete' && envelope.payload?.success === false) {
|
|
538
|
+
const errMsg =
|
|
539
|
+
envelope.payload?.error ||
|
|
540
|
+
(envelope.payload?.cancelled ? 'Workflow was cancelled' : 'Workflow execution failed');
|
|
541
|
+
const err = new Error(errMsg);
|
|
542
|
+
err.payload = envelope.payload;
|
|
543
|
+
reject(err);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
resolve(envelope.payload || { success: true });
|
|
547
|
+
} else if (envelope.kind === 'error' || envelope.payload?.state === 'task.fail') {
|
|
548
|
+
cleanup();
|
|
549
|
+
const errMsg = envelope.payload?.message || envelope.payload?.error || 'Task failed';
|
|
550
|
+
const err = new Error(errMsg);
|
|
551
|
+
err.payload = envelope.payload;
|
|
552
|
+
reject(err);
|
|
179
553
|
}
|
|
180
554
|
}
|
|
181
555
|
} catch {}
|
|
@@ -183,7 +557,7 @@ class RealtimeConnection {
|
|
|
183
557
|
|
|
184
558
|
this.ws.addEventListener('message', handler);
|
|
185
559
|
|
|
186
|
-
const
|
|
560
|
+
const broadcastMsg = {
|
|
187
561
|
topic: this.topic,
|
|
188
562
|
event: 'broadcast',
|
|
189
563
|
payload: {
|
|
@@ -191,39 +565,441 @@ class RealtimeConnection {
|
|
|
191
565
|
event: 'command',
|
|
192
566
|
payload: command,
|
|
193
567
|
},
|
|
194
|
-
ref:
|
|
195
|
-
join_ref: this.joinRef,
|
|
568
|
+
ref: this.nextRef(),
|
|
196
569
|
};
|
|
197
570
|
|
|
198
|
-
this.ws.send(JSON.stringify(
|
|
571
|
+
this.ws.send(JSON.stringify(broadcastMsg));
|
|
199
572
|
});
|
|
200
573
|
}
|
|
201
574
|
|
|
202
575
|
close() {
|
|
203
|
-
if (this.heartbeatTimer)
|
|
204
|
-
clearInterval(this.heartbeatTimer);
|
|
205
|
-
this.heartbeatTimer = null;
|
|
206
|
-
}
|
|
576
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
207
577
|
if (this.ws) {
|
|
208
578
|
try {
|
|
209
579
|
this.ws.close();
|
|
210
580
|
} catch {}
|
|
211
|
-
this.ws = null;
|
|
212
581
|
}
|
|
213
582
|
}
|
|
214
583
|
}
|
|
215
584
|
|
|
585
|
+
// ── UI Helpers ──────────────────────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
function createProgressTracker({ verbose = false, isJson = false, initialText = '' }) {
|
|
588
|
+
const steps = [];
|
|
589
|
+
let currentStatus = initialText;
|
|
590
|
+
let timer = null;
|
|
591
|
+
const isTTY = Boolean(process.stdout.isTTY) && !isJson && !verbose;
|
|
592
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
593
|
+
let frameIdx = 0;
|
|
594
|
+
|
|
595
|
+
if (isTTY) {
|
|
596
|
+
timer = setInterval(() => {
|
|
597
|
+
const frame = frames[frameIdx++ % frames.length];
|
|
598
|
+
const text = currentStatus ? ` \x1b[90m${currentStatus.slice(0, 65)}\x1b[0m` : '';
|
|
599
|
+
process.stdout.write(`\r\x1b[36m${frame}\x1b[0m${text}\x1b[K`);
|
|
600
|
+
}, 80);
|
|
601
|
+
} else if (!isJson && !verbose) {
|
|
602
|
+
if (initialText) console.log(initialText);
|
|
603
|
+
} else if (verbose && initialText) {
|
|
604
|
+
console.log(initialText);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
onStep(stepText) {
|
|
609
|
+
if (!stepText || typeof stepText !== 'string') return;
|
|
610
|
+
const firstLine = stepText.split('\n')[0].trim();
|
|
611
|
+
if (!firstLine) return;
|
|
612
|
+
steps.push(firstLine);
|
|
613
|
+
if (verbose) {
|
|
614
|
+
console.log(` \x1b[90m→ ${firstLine.slice(0, 100)}\x1b[0m`);
|
|
615
|
+
} else {
|
|
616
|
+
currentStatus = firstLine;
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
onStatus(statusText) {
|
|
620
|
+
if (!statusText || typeof statusText !== 'string') return;
|
|
621
|
+
const firstLine = statusText.split('\n')[0].trim();
|
|
622
|
+
if (!firstLine) return;
|
|
623
|
+
if (verbose) {
|
|
624
|
+
console.log(` \x1b[90mℹ ${firstLine.slice(0, 100)}\x1b[0m`);
|
|
625
|
+
} else {
|
|
626
|
+
currentStatus = firstLine;
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
stop() {
|
|
630
|
+
if (timer) {
|
|
631
|
+
clearInterval(timer);
|
|
632
|
+
timer = null;
|
|
633
|
+
}
|
|
634
|
+
if (isTTY) {
|
|
635
|
+
process.stdout.write('\r\x1b[K');
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
getSteps() {
|
|
639
|
+
return steps;
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── Commands ────────────────────────────────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
async function loginCommand(explicitCode) {
|
|
647
|
+
if (explicitCode) {
|
|
648
|
+
saveFromCode(explicitCode);
|
|
649
|
+
console.log('\x1b[32m✔ Successfully logged in!\x1b[0m\n');
|
|
650
|
+
await devicesCommand();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const port = 54321;
|
|
655
|
+
const authUrl = `https://kroki.ai/#/devices?cli_port=${port}`;
|
|
656
|
+
|
|
657
|
+
console.log(`\x1b[36mLogging in to Kroki...\x1b[0m`);
|
|
658
|
+
console.log(`Opening browser at: \x1b[33m${authUrl}\x1b[0m\n`);
|
|
659
|
+
|
|
660
|
+
openBrowser(authUrl);
|
|
661
|
+
|
|
662
|
+
console.log('Waiting for browser authentication (or paste your code from https://kroki.ai/#/devices below):');
|
|
663
|
+
|
|
664
|
+
let resolved = false;
|
|
665
|
+
let server = null;
|
|
666
|
+
let rl = null;
|
|
667
|
+
|
|
668
|
+
const finish = async code => {
|
|
669
|
+
if (resolved) return;
|
|
670
|
+
resolved = true;
|
|
671
|
+
if (server) {
|
|
672
|
+
try {
|
|
673
|
+
server.close();
|
|
674
|
+
} catch {}
|
|
675
|
+
}
|
|
676
|
+
if (rl) {
|
|
677
|
+
try {
|
|
678
|
+
rl.close();
|
|
679
|
+
} catch {}
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
saveFromCode(code);
|
|
683
|
+
console.log('\n\x1b[32m✔ Successfully logged in!\x1b[0m\n');
|
|
684
|
+
await devicesCommand();
|
|
685
|
+
} catch (e) {
|
|
686
|
+
console.error(`\x1b[31m✖ Login failed:\x1b[0m ${e.message}`);
|
|
687
|
+
process.exit(1);
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
try {
|
|
692
|
+
server = createServer((req, res) => {
|
|
693
|
+
const parsedUrl = new URL(req.url, `http://localhost:${port}`);
|
|
694
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
695
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
696
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
697
|
+
|
|
698
|
+
if (req.method === 'OPTIONS') {
|
|
699
|
+
res.writeHead(204);
|
|
700
|
+
res.end();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (req.method === 'POST') {
|
|
705
|
+
let body = '';
|
|
706
|
+
req.on('data', chunk => {
|
|
707
|
+
body += chunk;
|
|
708
|
+
});
|
|
709
|
+
req.on('end', () => {
|
|
710
|
+
try {
|
|
711
|
+
const data = JSON.parse(body);
|
|
712
|
+
if (data.token || data.code) {
|
|
713
|
+
const cfg = saveFromCode(data.code ? data.code : data);
|
|
714
|
+
const activeIds =
|
|
715
|
+
cfg.selectedDeviceIds && cfg.selectedDeviceIds.length > 0
|
|
716
|
+
? cfg.selectedDeviceIds
|
|
717
|
+
: Object.keys(cfg.devices || {});
|
|
718
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
719
|
+
res.end(JSON.stringify({ ok: true, selectedDeviceIds: activeIds }));
|
|
720
|
+
finish(data.code ? data.code : data);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
} catch {}
|
|
724
|
+
res.writeHead(400);
|
|
725
|
+
res.end(JSON.stringify({ error: 'invalid payload' }));
|
|
726
|
+
});
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const codeParam = parsedUrl.searchParams.get('code') || parsedUrl.searchParams.get('token');
|
|
731
|
+
if (codeParam) {
|
|
732
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
733
|
+
res.end(
|
|
734
|
+
`<html><body style="font-family:system-ui;text-align:center;padding:50px;background:#18181b;color:#f4f4f5"><h2>✔ Kroki CLI Authorized!</h2><p>You can return to your terminal.</p></body></html>`,
|
|
735
|
+
);
|
|
736
|
+
finish(codeParam);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
res.writeHead(404);
|
|
741
|
+
res.end();
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
server.listen(port, '127.0.0.1');
|
|
745
|
+
server.on('error', () => {
|
|
746
|
+
// Port in use, proceed with stdin prompt
|
|
747
|
+
});
|
|
748
|
+
} catch {}
|
|
749
|
+
|
|
750
|
+
rl = createInterface({
|
|
751
|
+
input: process.stdin,
|
|
752
|
+
output: process.stdout,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
rl.question('> ', answer => {
|
|
756
|
+
if (answer && answer.trim()) {
|
|
757
|
+
finish(answer.trim());
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function devicesCommand({ json = false, all = false, target = null, args = [] } = {}) {
|
|
763
|
+
const config = getConfig();
|
|
764
|
+
if (!config || !config.account || !config.account.token) {
|
|
765
|
+
console.error('\x1b[31mError: Not logged in.\x1b[0m');
|
|
766
|
+
console.error('Run \x1b[33mkroki login\x1b[0m to authenticate first.');
|
|
767
|
+
process.exit(1);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const devices = { ...(config.devices || {}) };
|
|
771
|
+
|
|
772
|
+
// Fetch accounts & devices from backend using control token
|
|
773
|
+
try {
|
|
774
|
+
const res = await fetch(`${config.account.supabaseUrl || DEFAULT_SUPABASE_URL}/functions/v1/get-control-token`, {
|
|
775
|
+
method: 'POST',
|
|
776
|
+
headers: {
|
|
777
|
+
Origin: 'https://kroki.ai',
|
|
778
|
+
'Content-Type': 'application/json',
|
|
779
|
+
apikey: config.account.anonKey || DEFAULT_ANON_KEY,
|
|
780
|
+
Authorization: `Bearer ${config.account.token}`,
|
|
781
|
+
},
|
|
782
|
+
body: JSON.stringify({ action: 'list_devices' }),
|
|
783
|
+
});
|
|
784
|
+
if (res.ok) {
|
|
785
|
+
const data = await res.json();
|
|
786
|
+
if (Array.isArray(data.accounts)) {
|
|
787
|
+
for (const acc of data.accounts) {
|
|
788
|
+
if (Array.isArray(acc.devices)) {
|
|
789
|
+
for (const d of acc.devices) {
|
|
790
|
+
const resolvedEmail =
|
|
791
|
+
acc.email ||
|
|
792
|
+
(acc.google_id === config.account.googleId ? config.account.email : null) ||
|
|
793
|
+
config.account.email ||
|
|
794
|
+
acc.label ||
|
|
795
|
+
acc.google_id;
|
|
796
|
+
devices[d.device_id] = {
|
|
797
|
+
deviceId: d.device_id,
|
|
798
|
+
deviceName: d.device_name || d.device_id,
|
|
799
|
+
platform: d.platform || 'mac',
|
|
800
|
+
account: resolvedEmail,
|
|
801
|
+
googleId: acc.google_id,
|
|
802
|
+
lastSeenAt: d.last_seen_at ? Date.parse(d.last_seen_at) : Date.now(),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
config.devices = devices;
|
|
808
|
+
saveConfig(config);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
} catch {}
|
|
812
|
+
|
|
813
|
+
const deviceList = Object.values(devices);
|
|
814
|
+
|
|
815
|
+
if (deviceList.length === 0) {
|
|
816
|
+
console.log('\n\x1b[33mNo connected browsers found in this account.\x1b[0m');
|
|
817
|
+
console.log('Connect Chrome from the Kroki extension or visit https://kroki.ai/#/devices\n');
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Ensure a default device exists
|
|
822
|
+
if (!config.defaultDeviceId || !devices[config.defaultDeviceId]) {
|
|
823
|
+
config.defaultDeviceId = deviceList[0].deviceId;
|
|
824
|
+
saveConfig(config);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const rawInput = (args.length > 0 ? args.join(' ') : target || '').trim();
|
|
828
|
+
|
|
829
|
+
// Query live presence
|
|
830
|
+
const { onlineIds, expired } = await fetchOnlineDeviceIds(config);
|
|
831
|
+
|
|
832
|
+
// 1. If arguments provided to select browser(s): e.g. "1", "1, 2", "all"
|
|
833
|
+
if (rawInput) {
|
|
834
|
+
if (rawInput.toLowerCase() === 'all') {
|
|
835
|
+
config.activeDeviceIds = [];
|
|
836
|
+
saveConfig(config);
|
|
837
|
+
console.log('\x1b[32m✔ All browsers are now active.\x1b[0m\n');
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const parts = rawInput
|
|
842
|
+
.split(/[,\s]+/)
|
|
843
|
+
.map(s => s.trim())
|
|
844
|
+
.filter(Boolean);
|
|
845
|
+
const chosen = [];
|
|
846
|
+
for (const p of parts) {
|
|
847
|
+
try {
|
|
848
|
+
const dev = resolveTargetDevice(config, p);
|
|
849
|
+
if (dev && !chosen.some(c => c.deviceId === dev.deviceId)) {
|
|
850
|
+
chosen.push(dev);
|
|
851
|
+
}
|
|
852
|
+
} catch (err) {
|
|
853
|
+
console.error(`\x1b[31m✖ Error resolving "${p}":\x1b[0m ${err.message}`);
|
|
854
|
+
process.exit(1);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (chosen.length > 0) {
|
|
859
|
+
config.activeDeviceIds = chosen.map(d => d.deviceId);
|
|
860
|
+
config.defaultDeviceId = chosen[0].deviceId;
|
|
861
|
+
saveConfig(config);
|
|
862
|
+
if (chosen.length === 1) {
|
|
863
|
+
const idx = deviceList.findIndex(d => d.deviceId === chosen[0].deviceId);
|
|
864
|
+
const isOnline = onlineIds.has(chosen[0].deviceId);
|
|
865
|
+
const statusTag = isOnline ? ' \x1b[32m● online\x1b[0m' : ' \x1b[90m○ offline\x1b[0m';
|
|
866
|
+
console.log(
|
|
867
|
+
`\x1b[32m✔ Active browser set to:\x1b[0m \x1b[36m[${idx + 1}] ${chosen[0].deviceName}\x1b[0m${statusTag} (${chosen[0].account || 'browser'})\n`,
|
|
868
|
+
);
|
|
869
|
+
if (!isOnline && !expired) {
|
|
870
|
+
console.log(
|
|
871
|
+
`\x1b[33m⚠ Note: This browser is currently offline. Open Chrome on this profile before running tasks.\x1b[0m\n`,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
} else {
|
|
875
|
+
console.log(`\x1b[32m✔ Active browsers set (${chosen.length}):\x1b[0m`);
|
|
876
|
+
let hasOffline = false;
|
|
877
|
+
chosen.forEach(c => {
|
|
878
|
+
const idx = deviceList.findIndex(d => d.deviceId === c.deviceId);
|
|
879
|
+
const isOnline = onlineIds.has(c.deviceId);
|
|
880
|
+
if (!isOnline) hasOffline = true;
|
|
881
|
+
const statusTag = isOnline ? ' \x1b[32m● online\x1b[0m' : ' \x1b[90m○ offline\x1b[0m';
|
|
882
|
+
console.log(` \x1b[36m[${idx + 1}]\x1b[0m ${c.deviceName}${statusTag} (${c.account || 'browser'})`);
|
|
883
|
+
});
|
|
884
|
+
console.log();
|
|
885
|
+
if (hasOffline && !expired) {
|
|
886
|
+
console.log(
|
|
887
|
+
`\x1b[33m⚠ Note: Some chosen browsers are offline. Tasks will only execute on online browsers.\x1b[0m\n`,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// 2. JSON output
|
|
896
|
+
if (json) {
|
|
897
|
+
const activeIds = config.activeDeviceIds && config.activeDeviceIds.length > 0 ? config.activeDeviceIds : null;
|
|
898
|
+
const list = activeIds && !all ? deviceList.filter(d => activeIds.includes(d.deviceId)) : deviceList;
|
|
899
|
+
const output = list.map(d => {
|
|
900
|
+
const idx = deviceList.findIndex(item => item.deviceId === d.deviceId);
|
|
901
|
+
const isOnline = onlineIds.has(d.deviceId);
|
|
902
|
+
return {
|
|
903
|
+
index: idx + 1,
|
|
904
|
+
deviceId: d.deviceId,
|
|
905
|
+
deviceName: d.deviceName,
|
|
906
|
+
account: d.account || null,
|
|
907
|
+
platform: d.platform || 'mac',
|
|
908
|
+
status: isOnline ? 'online' : 'offline',
|
|
909
|
+
isOnline,
|
|
910
|
+
isActive: !activeIds || activeIds.includes(d.deviceId),
|
|
911
|
+
isDefault: config.defaultDeviceId === d.deviceId,
|
|
912
|
+
lastSeenAt: d.lastSeenAt || null,
|
|
913
|
+
};
|
|
914
|
+
});
|
|
915
|
+
console.log(JSON.stringify(output, null, 2));
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// 3. Display list of browsers
|
|
920
|
+
const activeIds = config.activeDeviceIds && config.activeDeviceIds.length > 0 ? config.activeDeviceIds : null;
|
|
921
|
+
console.log(`\n\x1b[1mAvailable Kroki Browsers (${deviceList.length}):\x1b[0m`);
|
|
922
|
+
deviceList.forEach((d, idx) => {
|
|
923
|
+
const isAct = !activeIds || activeIds.includes(d.deviceId);
|
|
924
|
+
const isDef = config.defaultDeviceId === d.deviceId;
|
|
925
|
+
const isOnline = onlineIds.has(d.deviceId);
|
|
926
|
+
const statusTag = isOnline ? ' \x1b[32m● online\x1b[0m' : ' \x1b[90m○ offline\x1b[0m';
|
|
927
|
+
const mark = isAct ? '\x1b[32m✔\x1b[0m ' : ' ';
|
|
928
|
+
const defTag = isDef ? ' \x1b[33m[default]\x1b[0m' : '';
|
|
929
|
+
const accTag = d.account && !/^\d{15,}$/.test(d.account) ? ` \x1b[90m(${d.account})\x1b[0m` : '';
|
|
930
|
+
console.log(` ${mark}\x1b[36m[${idx + 1}]\x1b[0m ${d.deviceName || d.deviceId}${statusTag}${accTag}${defTag}`);
|
|
931
|
+
});
|
|
932
|
+
console.log();
|
|
933
|
+
|
|
934
|
+
if (activeIds && activeIds.length < deviceList.length) {
|
|
935
|
+
console.log(`\x1b[32mActive:\x1b[0m ${activeIds.length} of ${deviceList.length} browsers\n`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (expired) {
|
|
939
|
+
console.log(
|
|
940
|
+
`\x1b[33m⚠ Note: Session token has expired. Run "kroki login" to re-authenticate and refresh live presence.\x1b[0m\n`,
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Interactive selection if in terminal (TTY)
|
|
945
|
+
if (process.stdin.isTTY) {
|
|
946
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
947
|
+
rl.question('Select browser(s) [1..10, or "all"] (Enter to keep current): ', ans => {
|
|
948
|
+
rl.close();
|
|
949
|
+
const val = ans.trim();
|
|
950
|
+
if (!val) return;
|
|
951
|
+
if (val.toLowerCase() === 'all') {
|
|
952
|
+
config.activeDeviceIds = [];
|
|
953
|
+
saveConfig(config);
|
|
954
|
+
console.log('\n\x1b[32m✔ All browsers are now active.\x1b[0m\n');
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const parts = val
|
|
959
|
+
.split(/[,\s]+/)
|
|
960
|
+
.map(s => s.trim())
|
|
961
|
+
.filter(Boolean);
|
|
962
|
+
const chosen = [];
|
|
963
|
+
for (const p of parts) {
|
|
964
|
+
try {
|
|
965
|
+
const dev = resolveTargetDevice(config, p);
|
|
966
|
+
if (dev && !chosen.some(c => c.deviceId === dev.deviceId)) {
|
|
967
|
+
chosen.push(dev);
|
|
968
|
+
}
|
|
969
|
+
} catch (err) {
|
|
970
|
+
console.warn(`\x1b[33mWarning: ${err.message}\x1b[0m`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (chosen.length > 0) {
|
|
975
|
+
config.activeDeviceIds = chosen.map(d => d.deviceId);
|
|
976
|
+
config.defaultDeviceId = chosen[0].deviceId;
|
|
977
|
+
saveConfig(config);
|
|
978
|
+
console.log(`\n\x1b[32m✔ Active browser(s) set (${chosen.length}):\x1b[0m`);
|
|
979
|
+
chosen.forEach(c => {
|
|
980
|
+
const idx = deviceList.findIndex(d => d.deviceId === c.deviceId);
|
|
981
|
+
const isOnline = onlineIds.has(c.deviceId);
|
|
982
|
+
const statusTag = isOnline ? ' \x1b[32m● online\x1b[0m' : ' \x1b[90m○ offline\x1b[0m';
|
|
983
|
+
console.log(` \x1b[36m[${idx + 1}]\x1b[0m ${c.deviceName}${statusTag} (${c.account || 'browser'})`);
|
|
984
|
+
});
|
|
985
|
+
console.log();
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
} else {
|
|
989
|
+
console.log('\x1b[90mSelect browser: kroki devices <number> (e.g. kroki devices 1, kroki devices all)\x1b[0m\n');
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
216
993
|
async function pairCommand(code) {
|
|
217
994
|
if (!code) {
|
|
218
995
|
console.log(`
|
|
219
|
-
|
|
220
|
-
Requires an active \x1b[33mUltra\x1b[0m plan.
|
|
996
|
+
Pair Kroki CLI with a live browser profile.
|
|
221
997
|
|
|
222
998
|
1. Log in at \x1b[36mhttps://kroki.ai/#/devices\x1b[0m
|
|
223
999
|
2. Find your active browser profile
|
|
224
1000
|
3. Click \x1b[32m"Скопировать код для AI"\x1b[0m
|
|
225
1001
|
4. Run:
|
|
226
|
-
\x1b[
|
|
1002
|
+
\x1b[33mkroki pair <YOUR_CODE>\x1b[0m
|
|
227
1003
|
`);
|
|
228
1004
|
process.exit(0);
|
|
229
1005
|
}
|
|
@@ -257,73 +1033,259 @@ Requires an active \x1b[33mUltra\x1b[0m plan.
|
|
|
257
1033
|
process.exit(1);
|
|
258
1034
|
}
|
|
259
1035
|
|
|
260
|
-
|
|
261
|
-
...payload,
|
|
262
|
-
pairedAt: Date.now(),
|
|
263
|
-
});
|
|
264
|
-
|
|
1036
|
+
saveFromCode(code);
|
|
265
1037
|
conn.close();
|
|
266
1038
|
|
|
267
1039
|
console.log(`\x1b[32m✔ Successfully paired with "${name}".\x1b[0m`);
|
|
268
1040
|
console.log('You can now run tasks without entering the code:');
|
|
269
|
-
console.log(' \x1b[
|
|
270
|
-
console.log(' \x1b[
|
|
1041
|
+
console.log(' \x1b[33mkroki "open example.com and extract titles"\x1b[0m');
|
|
1042
|
+
console.log(' \x1b[33mkroki run "My Workflow Name"\x1b[0m\n');
|
|
271
1043
|
}
|
|
272
1044
|
|
|
273
|
-
async function
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
1045
|
+
async function runSingleDeviceTask(config, device, taskText, { verbose = false, json = false } = {}) {
|
|
1046
|
+
const devList = Object.values(config.devices || {});
|
|
1047
|
+
const devIdx = devList.findIndex(d => d.deviceId === device.deviceId) + 1;
|
|
1048
|
+
const idxStr = devIdx > 0 ? `[${devIdx}] ` : '';
|
|
1049
|
+
const devLabel = `${idxStr}${device.deviceName || device.deviceId}`;
|
|
1050
|
+
|
|
1051
|
+
if (isTokenExpired(config.account?.token)) {
|
|
1052
|
+
const errMsg = 'Session token has expired. Run "kroki login" to re-authenticate.';
|
|
1053
|
+
if (!json) {
|
|
1054
|
+
console.error('\x1b[31m✖ Error: Session token has expired.\x1b[0m');
|
|
1055
|
+
console.error('Run \x1b[33mkroki login\x1b[0m to re-authenticate.\n');
|
|
1056
|
+
}
|
|
1057
|
+
return { device, ok: false, error: errMsg, steps: [] };
|
|
280
1058
|
}
|
|
281
1059
|
|
|
282
|
-
const
|
|
283
|
-
|
|
1060
|
+
const targetGoogleId = device.googleId || config.account.googleId;
|
|
1061
|
+
const conn = new RealtimeConnection({
|
|
1062
|
+
...config.account,
|
|
1063
|
+
googleId: targetGoogleId,
|
|
1064
|
+
deviceId: device.deviceId,
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
try {
|
|
1068
|
+
await conn.connect();
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
return { device, ok: false, error: err.message, steps: [] };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (!conn.isDeviceOnline(device.deviceId)) {
|
|
1074
|
+
conn.close();
|
|
1075
|
+
const errMsg = `Browser ${devLabel} is offline. Make sure Google Chrome is open with the Kroki extension active on this profile.`;
|
|
1076
|
+
if (!json) {
|
|
1077
|
+
console.error(`\x1b[31m✖ Cannot run task: Browser ${devLabel} is offline.\x1b[0m`);
|
|
1078
|
+
console.error(` Make sure Google Chrome is open with the Kroki extension active on this profile.\n`);
|
|
1079
|
+
}
|
|
1080
|
+
return { device, ok: false, error: errMsg, steps: [] };
|
|
1081
|
+
}
|
|
284
1082
|
|
|
285
1083
|
const command = {
|
|
286
1084
|
v: 1,
|
|
287
|
-
id: `task-${Date.now()}`,
|
|
1085
|
+
id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
288
1086
|
kind: 'chat_task',
|
|
289
|
-
targetDeviceId:
|
|
290
|
-
taskId: `task-${Date.now()}`,
|
|
1087
|
+
targetDeviceId: device.deviceId,
|
|
1088
|
+
taskId: `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
291
1089
|
issuedAt: Date.now(),
|
|
292
1090
|
payload: {
|
|
293
1091
|
task: taskText,
|
|
294
1092
|
},
|
|
295
1093
|
};
|
|
296
1094
|
|
|
1095
|
+
const initialMsg = `\x1b[36m[Kroki: ${devLabel}]\x1b[0m Running: "${taskText}"...`;
|
|
1096
|
+
const tracker = createProgressTracker({ verbose, isJson: json, initialText: initialMsg });
|
|
1097
|
+
|
|
297
1098
|
try {
|
|
298
|
-
console.log(`\x1b[36m[Kroki: ${session.deviceName || 'Browser'}]\x1b[0m Running: "${taskText}"...`);
|
|
299
1099
|
const result = await conn.sendCommand(command, ev => {
|
|
300
1100
|
if (ev.kind === 'execution') {
|
|
301
1101
|
const details = ev.payload?.data?.details || ev.payload?.title || ev.payload?.type;
|
|
302
|
-
if (details
|
|
303
|
-
const firstLine = details.split('\n')[0].trim();
|
|
304
|
-
if (firstLine) console.log(` \x1b[90m→ ${firstLine.slice(0, 80)}\x1b[0m`);
|
|
305
|
-
}
|
|
1102
|
+
if (details) tracker.onStep(`[${devLabel}] ${details}`);
|
|
306
1103
|
}
|
|
307
1104
|
});
|
|
308
1105
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
console.log(JSON.stringify(result, null, 2));
|
|
315
|
-
}
|
|
1106
|
+
tracker.stop();
|
|
1107
|
+
return { device, ok: true, result, steps: tracker.getSteps() };
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
tracker.stop();
|
|
1110
|
+
return { device, ok: false, error: err.message, steps: tracker.getSteps() };
|
|
316
1111
|
} finally {
|
|
317
1112
|
conn.close();
|
|
318
1113
|
}
|
|
319
1114
|
}
|
|
320
1115
|
|
|
321
|
-
async function
|
|
322
|
-
const
|
|
323
|
-
if (!
|
|
324
|
-
console.error('\x1b[31mError: Not paired.\x1b[0m');
|
|
325
|
-
console.error('
|
|
326
|
-
|
|
1116
|
+
async function taskCommand(taskText, { verbose = false, json = false, device: targetQuery = null, all = false } = {}) {
|
|
1117
|
+
const config = getConfig();
|
|
1118
|
+
if (!config || !config.account || !config.account.token) {
|
|
1119
|
+
console.error('\x1b[31mError: Not logged in or paired.\x1b[0m');
|
|
1120
|
+
console.error('Run \x1b[33mkroki login\x1b[0m or \x1b[33mkroki pair <CODE>\x1b[0m first.\n');
|
|
1121
|
+
process.exit(1);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Auto-detect natural language requests to run a workflow (e.g. 'запусти Order Ali', 'run workflow Ali')
|
|
1125
|
+
const wfMatch = taskText.match(
|
|
1126
|
+
/^(?:запусти(?:ть)?|стартуй|run|launch|execute)\s+(?:воркфлоу|workflow\s+)?["']?([^"']+)["']?$/i,
|
|
1127
|
+
);
|
|
1128
|
+
if (wfMatch) {
|
|
1129
|
+
const candidate = wfMatch[1].trim();
|
|
1130
|
+
const resolvedDev = resolveTargetDevice(config, targetQuery);
|
|
1131
|
+
const targetGoogleId = resolvedDev?.googleId || config.account.googleId;
|
|
1132
|
+
try {
|
|
1133
|
+
const res = await fetch(`${config.account.supabaseUrl || DEFAULT_SUPABASE_URL}/functions/v1/get-control-token`, {
|
|
1134
|
+
method: 'POST',
|
|
1135
|
+
headers: {
|
|
1136
|
+
Origin: 'https://kroki.ai',
|
|
1137
|
+
'Content-Type': 'application/json',
|
|
1138
|
+
apikey: config.account.anonKey || DEFAULT_ANON_KEY,
|
|
1139
|
+
Authorization: `Bearer ${config.account.token}`,
|
|
1140
|
+
},
|
|
1141
|
+
body: JSON.stringify({ action: 'list_workflows', owner_google_id: targetGoogleId }),
|
|
1142
|
+
});
|
|
1143
|
+
if (res.ok) {
|
|
1144
|
+
const data = await res.json();
|
|
1145
|
+
if (Array.isArray(data.workflows)) {
|
|
1146
|
+
const q = candidate.toLowerCase();
|
|
1147
|
+
const match =
|
|
1148
|
+
data.workflows.find(w => w.id === candidate) ||
|
|
1149
|
+
data.workflows.find(w => (w.name || '').toLowerCase() === q) ||
|
|
1150
|
+
data.workflows.find(w => (w.name || '').toLowerCase().includes(q));
|
|
1151
|
+
if (match) {
|
|
1152
|
+
console.log(
|
|
1153
|
+
`\x1b[36m[Kroki]\x1b[0m Detected workflow "${match.name || match.id}". Launching workflow runner...\n`,
|
|
1154
|
+
);
|
|
1155
|
+
return workflowCommand(match.id, null, { verbose, json, device: targetQuery, taskText });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
} catch {}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const isAll =
|
|
1163
|
+
all ||
|
|
1164
|
+
targetQuery === 'all' ||
|
|
1165
|
+
targetQuery === 'both' ||
|
|
1166
|
+
/обоих браузер|всех браузер|на всех|на обоих|все браузеры|оба браузера/i.test(taskText);
|
|
1167
|
+
|
|
1168
|
+
let targetDevices = [];
|
|
1169
|
+
if (isAll) {
|
|
1170
|
+
targetDevices = Object.values(config.devices || {});
|
|
1171
|
+
} else {
|
|
1172
|
+
const device = resolveTargetDevice(config, targetQuery);
|
|
1173
|
+
if (device) targetDevices = [device];
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (targetDevices.length === 0) {
|
|
1177
|
+
console.error('\x1b[31mError: No target browser device found.\x1b[0m');
|
|
1178
|
+
console.error('Run \x1b[33mkroki devices\x1b[0m to select active browsers.\n');
|
|
1179
|
+
process.exit(1);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (targetDevices.length === 1) {
|
|
1183
|
+
const r = await runSingleDeviceTask(config, targetDevices[0], taskText, { verbose, json });
|
|
1184
|
+
if (!r.ok) {
|
|
1185
|
+
if (!verbose && r.steps && r.steps.length > 0) {
|
|
1186
|
+
console.error('\x1b[90mRecent steps before failure:\x1b[0m');
|
|
1187
|
+
for (const step of r.steps.slice(-10)) {
|
|
1188
|
+
console.error(` \x1b[90m→ ${step}\x1b[0m`);
|
|
1189
|
+
}
|
|
1190
|
+
console.error('');
|
|
1191
|
+
}
|
|
1192
|
+
if (json) {
|
|
1193
|
+
console.error(JSON.stringify({ success: false, error: r.error, steps: r.steps }, null, 2));
|
|
1194
|
+
} else {
|
|
1195
|
+
console.error(`\x1b[31m✖ Error: ${r.error}\x1b[0m`);
|
|
1196
|
+
}
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
if (json) {
|
|
1201
|
+
console.log(JSON.stringify(r.result, null, 2));
|
|
1202
|
+
} else {
|
|
1203
|
+
if (verbose) console.log('\x1b[32m✔ Task completed.\x1b[0m\n');
|
|
1204
|
+
if (r.result && typeof r.result === 'object' && r.result.message) {
|
|
1205
|
+
console.log(r.result.message);
|
|
1206
|
+
} else if (r.result && typeof r.result === 'object') {
|
|
1207
|
+
console.log(JSON.stringify(r.result, null, 2));
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Multi-device parallel dispatch
|
|
1214
|
+
console.log(`\x1b[36mDispatching task to ${targetDevices.length} browsers in parallel...\x1b[0m\n`);
|
|
1215
|
+
const results = await Promise.all(
|
|
1216
|
+
targetDevices.map(d => runSingleDeviceTask(config, d, taskText, { verbose, json })),
|
|
1217
|
+
);
|
|
1218
|
+
|
|
1219
|
+
if (json) {
|
|
1220
|
+
console.log(JSON.stringify(results, null, 2));
|
|
1221
|
+
} else {
|
|
1222
|
+
console.log('\n\x1b[32m✔ Completed on all target browsers:\x1b[0m');
|
|
1223
|
+
for (const r of results) {
|
|
1224
|
+
const devName = r.device.deviceName || r.device.deviceId;
|
|
1225
|
+
if (r.ok) {
|
|
1226
|
+
const msg = r.result && typeof r.result === 'object' && r.result.message ? r.result.message : 'Success';
|
|
1227
|
+
console.log(` \x1b[32m✔\x1b[0m \x1b[1m${devName}\x1b[0m: ${msg}`);
|
|
1228
|
+
} else {
|
|
1229
|
+
console.log(` \x1b[31m✖\x1b[0m \x1b[1m${devName}\x1b[0m: ${r.error}`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function workflowsCommand({ json = false, device: targetQuery = null } = {}) {
|
|
1236
|
+
const config = getConfig();
|
|
1237
|
+
if (!config || !config.account || !config.account.token) {
|
|
1238
|
+
console.error('\x1b[31mError: Not logged in.\x1b[0m');
|
|
1239
|
+
console.error('Run \x1b[33mkroki login\x1b[0m first.\n');
|
|
1240
|
+
process.exit(1);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const device = resolveTargetDevice(config, targetQuery);
|
|
1244
|
+
const targetGoogleId = device?.googleId || config.account.googleId;
|
|
1245
|
+
|
|
1246
|
+
const res = await fetch(`${config.account.supabaseUrl || DEFAULT_SUPABASE_URL}/functions/v1/get-control-token`, {
|
|
1247
|
+
method: 'POST',
|
|
1248
|
+
headers: {
|
|
1249
|
+
Origin: 'https://kroki.ai',
|
|
1250
|
+
'Content-Type': 'application/json',
|
|
1251
|
+
apikey: config.account.anonKey || DEFAULT_ANON_KEY,
|
|
1252
|
+
Authorization: `Bearer ${config.account.token}`,
|
|
1253
|
+
},
|
|
1254
|
+
body: JSON.stringify({ action: 'list_workflows', owner_google_id: targetGoogleId }),
|
|
1255
|
+
});
|
|
1256
|
+
if (!res.ok) {
|
|
1257
|
+
console.error('Failed to load workflows.');
|
|
1258
|
+
process.exit(1);
|
|
1259
|
+
}
|
|
1260
|
+
const data = await res.json();
|
|
1261
|
+
const list = data.workflows || [];
|
|
1262
|
+
if (json) {
|
|
1263
|
+
console.log(JSON.stringify(list, null, 2));
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
console.log(`\n\x1b[1mAvailable workflows for ${device?.deviceName || 'browser'}:\x1b[0m`);
|
|
1267
|
+
for (const wf of list) {
|
|
1268
|
+
console.log(` • \x1b[36m${wf.name}\x1b[0m \x1b[90m(${wf.id})\x1b[0m`);
|
|
1269
|
+
}
|
|
1270
|
+
console.log('\nRun workflow with:\n \x1b[33mkroki run "<name>"\x1b[0m\n');
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
async function workflowCommand(
|
|
1274
|
+
nameOrId,
|
|
1275
|
+
varsJson,
|
|
1276
|
+
{ verbose = false, json = false, device: targetQuery = null, taskText = null } = {},
|
|
1277
|
+
) {
|
|
1278
|
+
const config = getConfig();
|
|
1279
|
+
if (!config || !config.account || !config.account.token) {
|
|
1280
|
+
console.error('\x1b[31mError: Not logged in or paired.\x1b[0m');
|
|
1281
|
+
console.error('Run \x1b[33mkroki login\x1b[0m or \x1b[33mkroki pair <CODE>\x1b[0m first.\n');
|
|
1282
|
+
process.exit(1);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const device = resolveTargetDevice(config, targetQuery);
|
|
1286
|
+
if (!device) {
|
|
1287
|
+
console.error('\x1b[31mError: No target browser device found.\x1b[0m');
|
|
1288
|
+
console.error('Run \x1b[33mkroki devices\x1b[0m to select active browsers.\n');
|
|
327
1289
|
process.exit(1);
|
|
328
1290
|
}
|
|
329
1291
|
|
|
@@ -336,49 +1298,275 @@ async function workflowCommand(nameOrId, varsJson) {
|
|
|
336
1298
|
}
|
|
337
1299
|
}
|
|
338
1300
|
|
|
339
|
-
const
|
|
340
|
-
|
|
1301
|
+
const targetGoogleId = device.googleId || config.account.googleId;
|
|
1302
|
+
|
|
1303
|
+
// Resolve human workflow name to actual workflowId
|
|
1304
|
+
let targetWorkflowId = nameOrId;
|
|
1305
|
+
let resolvedName = nameOrId;
|
|
1306
|
+
|
|
1307
|
+
try {
|
|
1308
|
+
const res = await fetch(`${config.account.supabaseUrl || DEFAULT_SUPABASE_URL}/functions/v1/get-control-token`, {
|
|
1309
|
+
method: 'POST',
|
|
1310
|
+
headers: {
|
|
1311
|
+
Origin: 'https://kroki.ai',
|
|
1312
|
+
'Content-Type': 'application/json',
|
|
1313
|
+
apikey: config.account.anonKey || DEFAULT_ANON_KEY,
|
|
1314
|
+
Authorization: `Bearer ${config.account.token}`,
|
|
1315
|
+
},
|
|
1316
|
+
body: JSON.stringify({ action: 'list_workflows', owner_google_id: targetGoogleId }),
|
|
1317
|
+
});
|
|
1318
|
+
if (res.ok) {
|
|
1319
|
+
const data = await res.json();
|
|
1320
|
+
if (Array.isArray(data.workflows)) {
|
|
1321
|
+
const q = nameOrId.toLowerCase().trim();
|
|
1322
|
+
const match =
|
|
1323
|
+
data.workflows.find(w => w.id === nameOrId) ||
|
|
1324
|
+
data.workflows.find(w => (w.name || '').toLowerCase() === q) ||
|
|
1325
|
+
data.workflows.find(w => (w.name || '').toLowerCase().includes(q));
|
|
1326
|
+
if (match) {
|
|
1327
|
+
targetWorkflowId = match.id;
|
|
1328
|
+
resolvedName = match.name || match.id;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
} catch {}
|
|
1333
|
+
|
|
1334
|
+
if (isTokenExpired(config.account?.token)) {
|
|
1335
|
+
console.error('\x1b[31m✖ Error: Session token has expired.\x1b[0m');
|
|
1336
|
+
console.error('Run \x1b[33mkroki login\x1b[0m to re-authenticate.\n');
|
|
1337
|
+
process.exit(1);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const conn = new RealtimeConnection({
|
|
1341
|
+
...config.account,
|
|
1342
|
+
googleId: targetGoogleId,
|
|
1343
|
+
deviceId: device.deviceId,
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
try {
|
|
1347
|
+
await conn.connect();
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
console.error(`\x1b[31m✖ Error: ${err.message}\x1b[0m\n`);
|
|
1350
|
+
process.exit(1);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const devList = Object.values(config.devices || {});
|
|
1354
|
+
const devIdx = devList.findIndex(d => d.deviceId === device.deviceId) + 1;
|
|
1355
|
+
const idxStr = devIdx > 0 ? `[${devIdx}] ` : '';
|
|
1356
|
+
const devLabel = `${idxStr}${device.deviceName || device.deviceId}`;
|
|
1357
|
+
|
|
1358
|
+
if (!conn.isDeviceOnline(device.deviceId)) {
|
|
1359
|
+
conn.close();
|
|
1360
|
+
console.error(`\x1b[31m✖ Cannot run workflow: Browser ${devLabel} is offline.\x1b[0m`);
|
|
1361
|
+
console.error(` Make sure Google Chrome is open with the Kroki extension active on this profile.\n`);
|
|
1362
|
+
process.exit(1);
|
|
1363
|
+
}
|
|
341
1364
|
|
|
342
1365
|
const command = {
|
|
343
1366
|
v: 1,
|
|
344
1367
|
id: `wf-${Date.now()}`,
|
|
345
1368
|
kind: 'workflow_execute',
|
|
346
|
-
targetDeviceId:
|
|
1369
|
+
targetDeviceId: device.deviceId,
|
|
347
1370
|
taskId: `wf-${Date.now()}`,
|
|
348
1371
|
issuedAt: Date.now(),
|
|
349
1372
|
payload: {
|
|
350
|
-
workflowId:
|
|
1373
|
+
workflowId: targetWorkflowId,
|
|
1374
|
+
workflowName: resolvedName,
|
|
1375
|
+
task: taskText || `Run workflow "${resolvedName}"`,
|
|
351
1376
|
variables,
|
|
352
1377
|
},
|
|
353
1378
|
};
|
|
354
1379
|
|
|
1380
|
+
const devName = device.deviceName || device.deviceId || 'Browser';
|
|
1381
|
+
const initialMsg = `\x1b[36m[Kroki: ${devName}]\x1b[0m Started workflow "${resolvedName}" (ID: ${targetWorkflowId})`;
|
|
1382
|
+
if (!json) {
|
|
1383
|
+
console.log(initialMsg);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const tracker = createProgressTracker({ verbose, isJson: json, initialText: 'Running workflow...' });
|
|
1387
|
+
const startTime = Date.now();
|
|
1388
|
+
let stepCount = 0;
|
|
1389
|
+
const stepHistory = [];
|
|
1390
|
+
let currentStepStartTime = Date.now();
|
|
1391
|
+
|
|
355
1392
|
try {
|
|
356
|
-
console.log(`\x1b[36m[Kroki: ${session.deviceName || 'Browser'}]\x1b[0m Executing workflow "${nameOrId}"...`);
|
|
357
1393
|
const result = await conn.sendCommand(command, ev => {
|
|
358
|
-
if (ev.kind === '
|
|
359
|
-
|
|
1394
|
+
if (ev.kind === 'workflow_queued') {
|
|
1395
|
+
const queuePos = ev.payload?.position;
|
|
1396
|
+
tracker.onStatus(`Queued (position ${queuePos || 1}). Waiting for runner...`);
|
|
1397
|
+
} else if (ev.kind === 'workflow_step_start') {
|
|
1398
|
+
stepCount++;
|
|
1399
|
+
currentStepStartTime = Date.now();
|
|
1400
|
+
const type = ev.payload?.stepType || 'ACTION';
|
|
1401
|
+
const comment = ev.payload?.comment ? `: ${ev.payload.comment}` : '';
|
|
1402
|
+
const stepDesc = `[step ${stepCount}] ${type}${comment}`;
|
|
1403
|
+
stepHistory.push({
|
|
1404
|
+
index: stepCount,
|
|
1405
|
+
id: ev.payload?.stepId,
|
|
1406
|
+
type,
|
|
1407
|
+
comment: ev.payload?.comment || '',
|
|
1408
|
+
startedAt: currentStepStartTime,
|
|
1409
|
+
status: 'running',
|
|
1410
|
+
});
|
|
1411
|
+
if (verbose) {
|
|
1412
|
+
tracker.onStep(stepDesc);
|
|
1413
|
+
} else {
|
|
1414
|
+
tracker.onStatus(`Running "${resolvedName}" (step ${stepCount})...`);
|
|
1415
|
+
}
|
|
1416
|
+
} else if (ev.kind === 'workflow_step_result') {
|
|
1417
|
+
const lastStep = stepHistory[stepHistory.length - 1];
|
|
1418
|
+
const duration = ((Date.now() - currentStepStartTime) / 1000).toFixed(1);
|
|
1419
|
+
if (lastStep) {
|
|
1420
|
+
lastStep.duration = `${duration}s`;
|
|
1421
|
+
lastStep.status = ev.payload?.success ? 'success' : 'failed';
|
|
1422
|
+
lastStep.error = ev.payload?.error;
|
|
1423
|
+
}
|
|
1424
|
+
if (verbose) {
|
|
1425
|
+
const stepLabel = lastStep ? `[step ${lastStep.index}] ${lastStep.type}` : 'step';
|
|
1426
|
+
if (ev.payload?.success) {
|
|
1427
|
+
console.log(` \x1b[32m✔\x1b[0m ${stepLabel} completed in ${duration}s`);
|
|
1428
|
+
} else {
|
|
1429
|
+
console.log(` \x1b[31m✖\x1b[0m ${stepLabel} failed: ${ev.payload?.error || 'Unknown error'}`);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
360
1432
|
}
|
|
361
1433
|
});
|
|
362
1434
|
|
|
363
|
-
|
|
364
|
-
|
|
1435
|
+
tracker.stop();
|
|
1436
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
|
|
1437
|
+
|
|
1438
|
+
if (json) {
|
|
1439
|
+
console.log(
|
|
1440
|
+
JSON.stringify(
|
|
1441
|
+
{
|
|
1442
|
+
success: true,
|
|
1443
|
+
workflowId: targetWorkflowId,
|
|
1444
|
+
workflowName: resolvedName,
|
|
1445
|
+
device: devName,
|
|
1446
|
+
elapsed,
|
|
1447
|
+
totalSteps: stepCount,
|
|
1448
|
+
steps: stepHistory,
|
|
1449
|
+
result,
|
|
1450
|
+
},
|
|
1451
|
+
null,
|
|
1452
|
+
2,
|
|
1453
|
+
),
|
|
1454
|
+
);
|
|
1455
|
+
} else {
|
|
1456
|
+
console.log(
|
|
1457
|
+
`\x1b[32m✔ Workflow "${resolvedName}" completed successfully on ${devName} in ${elapsed} (${stepCount} steps executed).\x1b[0m`,
|
|
1458
|
+
);
|
|
1459
|
+
if (verbose) {
|
|
1460
|
+
if (stepHistory.length > 0) {
|
|
1461
|
+
console.log('\n\x1b[1mExecution Details:\x1b[0m');
|
|
1462
|
+
for (const s of stepHistory) {
|
|
1463
|
+
const icon = s.status === 'success' ? '\x1b[32m✔\x1b[0m' : '\x1b[31m✖\x1b[0m';
|
|
1464
|
+
const comment = s.comment ? ` - ${s.comment}` : '';
|
|
1465
|
+
const dur = s.duration ? ` (${s.duration})` : '';
|
|
1466
|
+
console.log(` ${icon} [step ${s.index}] \x1b[1m${s.type}\x1b[0m${comment}${dur}`);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (result && typeof result === 'object' && Object.keys(result).length > 0) {
|
|
1470
|
+
console.log('\n\x1b[1mResult Payload:\x1b[0m');
|
|
1471
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1472
|
+
}
|
|
1473
|
+
console.log('');
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
} catch (err) {
|
|
1477
|
+
tracker.stop();
|
|
1478
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
|
|
1479
|
+
const isCancelled = err.payload?.cancelled || /cancelled/i.test(err.message);
|
|
1480
|
+
const failStatus = isCancelled ? 'was cancelled' : 'failed';
|
|
1481
|
+
|
|
1482
|
+
if (json) {
|
|
1483
|
+
console.error(
|
|
1484
|
+
JSON.stringify(
|
|
1485
|
+
{
|
|
1486
|
+
success: false,
|
|
1487
|
+
cancelled: isCancelled,
|
|
1488
|
+
workflowId: targetWorkflowId,
|
|
1489
|
+
workflowName: resolvedName,
|
|
1490
|
+
device: devName,
|
|
1491
|
+
elapsed,
|
|
1492
|
+
error: err.message,
|
|
1493
|
+
totalSteps: stepCount,
|
|
1494
|
+
steps: stepHistory,
|
|
1495
|
+
},
|
|
1496
|
+
null,
|
|
1497
|
+
2,
|
|
1498
|
+
),
|
|
1499
|
+
);
|
|
1500
|
+
} else {
|
|
1501
|
+
console.error(
|
|
1502
|
+
`\x1b[31m✖ Workflow "${resolvedName}" ${failStatus} on ${devName} in ${elapsed}: ${err.message}\x1b[0m`,
|
|
1503
|
+
);
|
|
1504
|
+
if (stepHistory.length > 0) {
|
|
1505
|
+
console.error('\n\x1b[90mRecent steps before failure:\x1b[0m');
|
|
1506
|
+
for (const s of stepHistory.slice(-5)) {
|
|
1507
|
+
const icon = s.status === 'failed' ? '\x1b[31m✖\x1b[0m' : '\x1b[90m→\x1b[0m';
|
|
1508
|
+
const comment = s.comment ? ` - ${s.comment}` : '';
|
|
1509
|
+
const dur = s.duration ? ` (${s.duration})` : '';
|
|
1510
|
+
console.error(` ${icon} [step ${s.index}] ${s.type}${comment}${dur}`);
|
|
1511
|
+
}
|
|
1512
|
+
console.error('');
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
process.exit(1);
|
|
365
1516
|
} finally {
|
|
366
1517
|
conn.close();
|
|
367
1518
|
}
|
|
368
1519
|
}
|
|
369
1520
|
|
|
370
|
-
function statusCommand() {
|
|
371
|
-
const
|
|
372
|
-
if (!
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
1521
|
+
function statusCommand({ json = false } = {}) {
|
|
1522
|
+
const config = getConfig();
|
|
1523
|
+
if (!config || !config.account || !config.account.token) {
|
|
1524
|
+
if (json) {
|
|
1525
|
+
console.log(JSON.stringify({ authenticated: false, paired: false }, null, 2));
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
console.log('Not logged in.');
|
|
1529
|
+
console.log('Run \x1b[33mkroki login\x1b[0m or \x1b[33mkroki pair <CODE>\x1b[0m to connect.');
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const defaultDevice = (config.devices && config.defaultDeviceId && config.devices[config.defaultDeviceId]) || null;
|
|
1534
|
+
const selectedCount = (config.selectedDeviceIds || []).length;
|
|
1535
|
+
const totalCount = Object.keys(config.devices || {}).length;
|
|
1536
|
+
|
|
1537
|
+
if (json) {
|
|
1538
|
+
console.log(
|
|
1539
|
+
JSON.stringify(
|
|
1540
|
+
{
|
|
1541
|
+
authenticated: true,
|
|
1542
|
+
googleId: config.account.googleId,
|
|
1543
|
+
defaultDevice: defaultDevice
|
|
1544
|
+
? {
|
|
1545
|
+
deviceId: defaultDevice.deviceId,
|
|
1546
|
+
deviceName: defaultDevice.deviceName,
|
|
1547
|
+
platform: defaultDevice.platform,
|
|
1548
|
+
}
|
|
1549
|
+
: null,
|
|
1550
|
+
selectedDevicesCount: selectedCount,
|
|
1551
|
+
totalDevicesCount: totalCount,
|
|
1552
|
+
},
|
|
1553
|
+
null,
|
|
1554
|
+
2,
|
|
1555
|
+
),
|
|
1556
|
+
);
|
|
376
1557
|
return;
|
|
377
1558
|
}
|
|
1559
|
+
|
|
1560
|
+
console.log(`\x1b[32m✔ Authenticated\x1b[0m (Account ID: ${config.account.googleId})`);
|
|
1561
|
+
if (defaultDevice) {
|
|
1562
|
+
console.log(
|
|
1563
|
+
` Default browser: \x1b[36m${defaultDevice.deviceName || defaultDevice.deviceId}\x1b[0m (${defaultDevice.account || defaultDevice.platform || 'browser'})`,
|
|
1564
|
+
);
|
|
1565
|
+
console.log(` Device ID: ${defaultDevice.deviceId}`);
|
|
1566
|
+
}
|
|
378
1567
|
console.log(
|
|
379
|
-
|
|
1568
|
+
` All ${totalCount} browsers are available (use -d <number|name> to target, or 'kroki devices' to list)`,
|
|
380
1569
|
);
|
|
381
|
-
console.log(` Device ID: ${session.deviceId}`);
|
|
382
1570
|
}
|
|
383
1571
|
|
|
384
1572
|
function helpCommand() {
|
|
@@ -386,11 +1574,29 @@ function helpCommand() {
|
|
|
386
1574
|
\x1b[1mKroki CLI\x1b[0m — Browser Automation & AI Agent Bridge for Google Chrome
|
|
387
1575
|
|
|
388
1576
|
\x1b[1mUsage:\x1b[0m
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1577
|
+
kroki login [code] Log in to your Kroki account
|
|
1578
|
+
kroki devices [1..N | all] View or select working browser(s)
|
|
1579
|
+
kroki "<task>" [options] Send an AI task to the live browser
|
|
1580
|
+
kroki run "<workflow>" [json] [opts] Execute a saved workflow
|
|
1581
|
+
kroki list / workflows [options] List available workflows
|
|
1582
|
+
kroki status [options] View active account and browsers
|
|
1583
|
+
kroki clear / logout Clear local credentials and logout
|
|
1584
|
+
|
|
1585
|
+
\x1b[1mOptions:\x1b[0m
|
|
1586
|
+
-d, --device <number|name|id> Target specific browser on-demand
|
|
1587
|
+
-v, --verbose Show real-time execution steps and logs
|
|
1588
|
+
-j, --json Output result as JSON
|
|
1589
|
+
-h, --help Show help
|
|
1590
|
+
|
|
1591
|
+
\x1b[1mExamples:\x1b[0m
|
|
1592
|
+
kroki devices
|
|
1593
|
+
kroki devices 1
|
|
1594
|
+
kroki devices 1, 2
|
|
1595
|
+
kroki devices all
|
|
1596
|
+
kroki "open apple.com"
|
|
1597
|
+
kroki "check orders" -d 2
|
|
1598
|
+
kroki run "Order Ali"
|
|
1599
|
+
kroki "open google.com" --all
|
|
394
1600
|
|
|
395
1601
|
\x1b[1mDashboard:\x1b[0m
|
|
396
1602
|
https://kroki.ai/#/devices
|
|
@@ -400,33 +1606,86 @@ function helpCommand() {
|
|
|
400
1606
|
// ── Entry Point ─────────────────────────────────────────────────────────────
|
|
401
1607
|
|
|
402
1608
|
const rawArgs = process.argv.slice(2);
|
|
403
|
-
|
|
1609
|
+
let verbose = false;
|
|
1610
|
+
let json = false;
|
|
1611
|
+
let targetDevice = process.env.KROKI_DEVICE || null;
|
|
1612
|
+
let runOnAll = false;
|
|
1613
|
+
const filteredArgs = [];
|
|
1614
|
+
|
|
1615
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
1616
|
+
const arg = rawArgs[i];
|
|
1617
|
+
if (arg === '--verbose' || arg === '-v') {
|
|
1618
|
+
verbose = true;
|
|
1619
|
+
} else if (arg === '--json' || arg === '-j') {
|
|
1620
|
+
json = true;
|
|
1621
|
+
} else if (arg === '--all' || arg === '-a') {
|
|
1622
|
+
runOnAll = true;
|
|
1623
|
+
} else if (arg === '-d' || arg === '--device') {
|
|
1624
|
+
targetDevice = rawArgs[++i] || null;
|
|
1625
|
+
} else if (arg.startsWith('--device=')) {
|
|
1626
|
+
targetDevice = arg.slice(9);
|
|
1627
|
+
} else if (arg.startsWith('-d=')) {
|
|
1628
|
+
targetDevice = arg.slice(3);
|
|
1629
|
+
} else {
|
|
1630
|
+
filteredArgs.push(arg);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const first = filteredArgs[0] || 'help';
|
|
404
1635
|
|
|
405
1636
|
switch (first) {
|
|
1637
|
+
case 'login':
|
|
1638
|
+
case 'auth':
|
|
1639
|
+
loginCommand(filteredArgs[1]).catch(e => {
|
|
1640
|
+
console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
|
|
1641
|
+
process.exit(1);
|
|
1642
|
+
});
|
|
1643
|
+
break;
|
|
1644
|
+
|
|
1645
|
+
case 'device':
|
|
1646
|
+
case 'devices':
|
|
1647
|
+
case 'use':
|
|
1648
|
+
case 'select':
|
|
1649
|
+
case 'default':
|
|
1650
|
+
devicesCommand({ json, all: runOnAll, target: targetDevice, args: filteredArgs.slice(1) }).catch(e => {
|
|
1651
|
+
console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
|
|
1652
|
+
process.exit(1);
|
|
1653
|
+
});
|
|
1654
|
+
break;
|
|
1655
|
+
|
|
406
1656
|
case 'pair':
|
|
407
1657
|
case 'connect':
|
|
408
|
-
pairCommand(
|
|
1658
|
+
pairCommand(filteredArgs[1]).catch(e => {
|
|
409
1659
|
console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
|
|
410
1660
|
process.exit(1);
|
|
411
1661
|
});
|
|
412
1662
|
break;
|
|
413
1663
|
|
|
414
1664
|
case 'status':
|
|
415
|
-
statusCommand();
|
|
1665
|
+
statusCommand({ json });
|
|
416
1666
|
break;
|
|
417
1667
|
|
|
418
1668
|
case 'run':
|
|
419
1669
|
case 'workflow':
|
|
420
|
-
workflowCommand(
|
|
1670
|
+
workflowCommand(filteredArgs[1], filteredArgs[2], { verbose, json, device: targetDevice }).catch(e => {
|
|
1671
|
+
console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
|
|
1672
|
+
process.exit(1);
|
|
1673
|
+
});
|
|
1674
|
+
break;
|
|
1675
|
+
|
|
1676
|
+
case 'list':
|
|
1677
|
+
case 'workflows':
|
|
1678
|
+
workflowsCommand({ json, device: targetDevice }).catch(e => {
|
|
421
1679
|
console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
|
|
422
1680
|
process.exit(1);
|
|
423
1681
|
});
|
|
424
1682
|
break;
|
|
425
1683
|
|
|
426
1684
|
case 'clear':
|
|
1685
|
+
case 'logout':
|
|
427
1686
|
case 'disconnect':
|
|
428
|
-
|
|
429
|
-
console.log('\x1b[32m✔
|
|
1687
|
+
clearAllSessions();
|
|
1688
|
+
console.log('\x1b[32m✔ Logged out and sessions cleared.\x1b[0m');
|
|
430
1689
|
break;
|
|
431
1690
|
|
|
432
1691
|
case 'help':
|
|
@@ -435,10 +1694,16 @@ switch (first) {
|
|
|
435
1694
|
helpCommand();
|
|
436
1695
|
break;
|
|
437
1696
|
|
|
438
|
-
default:
|
|
439
|
-
|
|
1697
|
+
default: {
|
|
1698
|
+
const taskText = filteredArgs.join(' ').trim();
|
|
1699
|
+
if (!taskText) {
|
|
1700
|
+
helpCommand();
|
|
1701
|
+
break;
|
|
1702
|
+
}
|
|
1703
|
+
taskCommand(taskText, { verbose, json, device: targetDevice, all: runOnAll }).catch(e => {
|
|
440
1704
|
console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
|
|
441
1705
|
process.exit(1);
|
|
442
1706
|
});
|
|
443
1707
|
break;
|
|
1708
|
+
}
|
|
444
1709
|
}
|