lobsterboard 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0] - 2026-03-17
4
+
5
+ ### Added
6
+ - **Enhanced Gemini CLI integration** — auto-detect all available Gemini CLI quota buckets (including new 3.x models) instead of hardcoded 2.x allowlist — thanks @mastash3ff!
7
+ - **Auto-refresh OAuth tokens** — Gemini CLI tokens now automatically refresh to survive multi-machine rotation, preventing authentication failures — thanks @mastash3ff!
8
+
9
+ ### Changed
10
+ - **Future-proof model support** — Gemini CLI collector now automatically surfaces new quota windows when Google adds them
11
+
3
12
  ## [0.3.1] - 2026-02-28
4
13
 
5
14
  ### Fixed
package/README.md CHANGED
@@ -169,7 +169,7 @@ Track your AI coding subscriptions in real-time. Inspired by [OpenUsage](https:/
169
169
  | Codex CLI | Session, weekly, code reviews | Run `codex` once |
170
170
  | GitHub Copilot | Premium, chat, completions | Run `gh auth login` |
171
171
  | Cursor | Credits, usage breakdown | Just use Cursor IDE |
172
- | Gemini CLI | Pro, flash models | Run `gemini` once |
172
+ | Gemini CLI | All available Gemini CLI quota buckets | Run `gemini` once |
173
173
  | Amp | Free tier, credits | Run `amp` once |
174
174
  | Factory / Droid | Standard, premium tokens | Run `factory` once |
175
175
  | Kimi Code | Session, weekly | Run `kimi` once |
@@ -1,4 +1,4 @@
1
- /* LobsterBoard v0.6.2 - Dashboard Styles */
1
+ /* LobsterBoard v0.7.0 - Dashboard Styles */
2
2
  /* LobsterBoard Dashboard - Generated Styles */
3
3
 
4
4
  :root {
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.6.2
2
+ * LobsterBoard v0.7.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.6.2
2
+ * LobsterBoard v0.7.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.6.2
2
+ * LobsterBoard v0.7.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.6.2
2
+ * LobsterBoard v0.7.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
package/js/widgets.js CHANGED
@@ -304,7 +304,7 @@ const WIDGETS = {
304
304
  name: 'Local Weather',
305
305
  icon: '🌡️',
306
306
  category: 'small',
307
- description: 'Shows current weather for a single location using wttr.in (no API key needed).',
307
+ description: 'Shows current weather for a single location using Open-Meteo (no API key needed).',
308
308
  defaultWidth: 200,
309
309
  defaultHeight: 120,
310
310
  hasApiKey: false,
@@ -332,29 +332,35 @@ const WIDGETS = {
332
332
  </div>
333
333
  </div>`,
334
334
  generateJs: (props) => `
335
- // Weather Widget: ${props.id} (uses free wttr.in API - no key needed)
335
+ // Weather Widget: ${props.id} (uses free Open-Meteo API - no key needed)
336
+ const WMO_DESC = {0:'Clear sky',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',51:'Light drizzle',53:'Drizzle',55:'Dense drizzle',61:'Slight rain',63:'Moderate rain',65:'Heavy rain',71:'Slight snow',73:'Moderate snow',75:'Heavy snow',80:'Slight showers',81:'Moderate showers',82:'Violent showers',95:'Thunderstorm',96:'Hail thunderstorm',99:'Heavy hail'};
337
+ function wmoIcon(code) {
338
+ if (code <= 1) return 'weather-sunny';
339
+ if (code <= 3) return 'weather-cloudy';
340
+ if (code >= 51 && code <= 82) return 'weather-rainy';
341
+ if (code >= 71 && code <= 77) return 'weather-snowy';
342
+ if (code >= 95) return 'weather-rainy';
343
+ return 'weather';
344
+ }
336
345
  async function update_${props.id.replace(/-/g, '_')}() {
337
346
  const valEl = document.getElementById('${props.id}-value');
338
347
  const labelEl = document.getElementById('${props.id}-label');
339
348
  const iconEl = document.getElementById('${props.id}-icon');
340
349
  try {
341
- const location = encodeURIComponent('${props.location || 'Atlanta'}');
342
- const res = await fetch('https://wttr.in/' + location + '?format=j1');
350
+ const loc = '${props.location || 'Atlanta'}';
351
+ const geoRes = await fetch('https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(loc) + '&count=1');
352
+ const geoData = await geoRes.json();
353
+ if (!geoData.results || !geoData.results.length) throw new Error('City not found');
354
+ const {latitude, longitude} = geoData.results[0];
355
+ const tempUnit = '${props.units}' === 'C' ? 'celsius' : 'fahrenheit';
356
+ const res = await fetch('https://api.open-meteo.com/v1/forecast?latitude=' + latitude + '&longitude=' + longitude + '&current=temperature_2m,weathercode,windspeed_10m&temperature_unit=' + tempUnit);
343
357
  const data = await res.json();
344
- const current = data.current_condition[0];
345
- const temp = '${props.units}' === 'C' ? current.temp_C : current.temp_F;
358
+ const c = data.current;
346
359
  const unit = '${props.units}' === 'C' ? '°C' : '°F';
347
- valEl.textContent = temp + unit;
348
- labelEl.textContent = current.weatherDesc[0].value;
349
- // Update icon based on condition
350
- const code = parseInt(current.weatherCode);
351
- let iconId = 'weather';
352
- if (code === 113) iconId = 'weather-sunny';
353
- else if (code === 116 || code === 119) iconId = 'weather-cloudy';
354
- else if (code >= 176 && code <= 359) iconId = 'weather-rainy';
355
- else if (code >= 368 && code <= 395) iconId = 'weather-snowy';
360
+ valEl.textContent = Math.round(c.temperature_2m) + unit;
361
+ labelEl.textContent = WMO_DESC[c.weathercode] || 'Unknown';
362
+ const iconId = wmoIcon(c.weathercode);
356
363
  iconEl.setAttribute('data-icon', iconId);
357
- // Update emoji fallback for non-themed views
358
364
  const icons = window.WIDGET_ICONS || {};
359
365
  iconEl.textContent = icons[iconId] ? icons[iconId].emoji : '🌡️';
360
366
  } catch (e) {
@@ -399,35 +405,42 @@ const WIDGETS = {
399
405
  </div>
400
406
  </div>`,
401
407
  generateJs: (props) => `
402
- // Multi Weather Widget: ${props.id} (uses free wttr.in API - no key needed)
408
+ // Multi Weather Widget: ${props.id} (uses free Open-Meteo API - no key needed)
409
+ const WMO_DESC2 = {0:'Clear',1:'Clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',51:'Drizzle',53:'Drizzle',55:'Drizzle',61:'Rain',63:'Rain',65:'Heavy rain',71:'Snow',73:'Snow',75:'Heavy snow',80:'Showers',81:'Showers',82:'Showers',95:'Storm',96:'Hail',99:'Hail'};
410
+ function wmoIcon2(code) {
411
+ if (code <= 1) return 'weather-sunny';
412
+ if (code <= 3) return 'weather-cloudy';
413
+ if (code >= 51 && code <= 82) return 'weather-rainy';
414
+ if (code >= 71 && code <= 77) return 'weather-snowy';
415
+ if (code >= 95) return 'weather-rainy';
416
+ return 'weather';
417
+ }
403
418
  async function update_${props.id.replace(/-/g, '_')}() {
404
419
  const locations = '${props.locations || 'New York; London; Tokyo'}'.split(';').map(l => l.trim());
405
420
  const container = document.getElementById('${props.id}-list');
406
- const unit = '${props.units}' === 'C' ? 'C' : 'F';
407
- const unitSymbol = unit === 'C' ? '°C' : '°F';
421
+ const tempUnit = '${props.units}' === 'C' ? 'celsius' : 'fahrenheit';
422
+ const unitSymbol = '${props.units}' === 'C' ? '°C' : '°F';
408
423
 
409
424
  const results = await Promise.all(locations.map(async (loc) => {
410
425
  try {
411
- const res = await fetch('https://wttr.in/' + encodeURIComponent(loc) + '?format=j1');
426
+ const geoRes = await fetch('https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(loc) + '&count=1');
427
+ const geoData = await geoRes.json();
428
+ if (!geoData.results || !geoData.results.length) return { loc, temp: 'N/A', iconId: 'weather', emoji: '❓' };
429
+ const {latitude, longitude} = geoData.results[0];
430
+ const res = await fetch('https://api.open-meteo.com/v1/forecast?latitude=' + latitude + '&longitude=' + longitude + '&current=temperature_2m,weathercode&temperature_unit=' + tempUnit);
412
431
  const data = await res.json();
413
- const current = data.current_condition[0];
414
- const temp = unit === 'C' ? current.temp_C : current.temp_F;
415
- const code = parseInt(current.weatherCode);
416
- let iconId = 'weather';
417
- if (code === 113) iconId = 'weather-sunny';
418
- else if (code === 116 || code === 119) iconId = 'weather-cloudy';
419
- else if (code >= 176 && code <= 359) iconId = 'weather-rainy';
420
- else if (code >= 368 && code <= 395) iconId = 'weather-snowy';
432
+ const c = data.current;
433
+ const iconId = wmoIcon2(c.weathercode);
421
434
  const icons = window.WIDGET_ICONS || {};
422
435
  const emoji = icons[iconId] ? icons[iconId].emoji : '🌡️';
423
- return { loc, temp, iconId, emoji, desc: current.weatherDesc[0].value };
436
+ return { loc, temp: Math.round(c.temperature_2m), iconId, emoji };
424
437
  } catch (e) {
425
- return { loc, temp: 'N/A', iconId: 'weather', emoji: '❓', desc: 'Error' };
438
+ return { loc, temp: 'N/A', iconId: 'weather', emoji: '❓' };
426
439
  }
427
440
  }));
428
441
 
429
442
  container.innerHTML = results.map(r =>
430
- '<div class="weather-row"><span class="weather-icon lb-icon" data-icon="' + _esc(r.iconId) + '">' + _esc(r.emoji) + '</span><span class="weather-loc">' + _esc(r.loc) + '</span><span class="weather-temp">' + _esc(r.temp) + _esc(unitSymbol) + '</span></div>'
443
+ '<div class="weather-row"><span class="weather-icon lb-icon" data-icon="' + _esc(r.iconId) + '">' + _esc(r.emoji) + '</span><span class="weather-loc">' + _esc(r.loc) + '</span><span class="weather-temp">' + _esc(String(r.temp)) + _esc(unitSymbol) + '</span></div>'
431
444
  ).join('');
432
445
  }
433
446
  update_${props.id.replace(/-/g, '_')}();
@@ -3728,32 +3741,34 @@ const WIDGETS = {
3728
3741
  </div>
3729
3742
  </div>`,
3730
3743
  generateJs: (props) => `
3731
- // World Clock Widget: ${props.id} (uses wttr.in for timezone data)
3744
+ // World Clock Widget: ${props.id} (pure Intl.DateTimeFormat - no API needed)
3745
+ const CITY_TZ_MAP = {
3746
+ 'New York': 'America/New_York', 'Los Angeles': 'America/Los_Angeles', 'Chicago': 'America/Chicago',
3747
+ 'London': 'Europe/London', 'Paris': 'Europe/Paris', 'Berlin': 'Europe/Berlin',
3748
+ 'Tokyo': 'Asia/Tokyo', 'Sydney': 'Australia/Sydney', 'Dubai': 'Asia/Dubai',
3749
+ 'Singapore': 'Asia/Singapore', 'Hong Kong': 'Asia/Hong_Kong', 'Mumbai': 'Asia/Kolkata',
3750
+ 'Shanghai': 'Asia/Shanghai', 'Seoul': 'Asia/Seoul', 'Moscow': 'Europe/Moscow',
3751
+ 'Istanbul': 'Europe/Istanbul', 'Bangkok': 'Asia/Bangkok', 'Toronto': 'America/Toronto',
3752
+ 'Heidenheim': 'Europe/Berlin', 'Vienna': 'Europe/Vienna', 'Zurich': 'Europe/Zurich',
3753
+ 'Amsterdam': 'Europe/Amsterdam', 'Rome': 'Europe/Rome', 'Madrid': 'Europe/Madrid',
3754
+ 'São Paulo': 'America/Sao_Paulo', 'Mexico City': 'America/Mexico_City',
3755
+ 'Graz': 'Europe/Vienna', 'Munich': 'Europe/Berlin', 'Frankfurt': 'Europe/Berlin',
3756
+ 'Santiago': 'America/Santiago', 'Lima': 'America/Lima'
3757
+ };
3732
3758
  const locs_${props.id.replace(/-/g, '_')} = '${props.locations || 'New York; London; Tokyo'}'.split(';').map(s => s.trim());
3733
3759
  const hour12_${props.id.replace(/-/g, '_')} = ${!props.format24h};
3734
3760
 
3735
- async function update_${props.id.replace(/-/g, '_')}() {
3761
+ function update_${props.id.replace(/-/g, '_')}() {
3736
3762
  const container = document.getElementById('${props.id}-clocks');
3737
- const results = await Promise.all(locs_${props.id.replace(/-/g, '_')}.map(async (loc) => {
3763
+ const now = new Date();
3764
+ const results = locs_${props.id.replace(/-/g, '_')}.map(loc => {
3765
+ const tz = CITY_TZ_MAP[loc] || CITY_TZ_MAP[Object.keys(CITY_TZ_MAP).find(k => k.toLowerCase() === loc.toLowerCase())] || null;
3766
+ if (!tz) return { city: loc, time: '(unknown tz)' };
3738
3767
  try {
3739
- const res = await fetch('https://wttr.in/' + encodeURIComponent(loc) + '?format=j1');
3740
- const data = await res.json();
3741
- const area = data.nearest_area[0];
3742
- const city = area.areaName[0].value;
3743
- const localTime = data.current_condition[0].localObsDateTime;
3744
- // Parse the time from format "2026-02-07 12:30 AM"
3745
- const timePart = localTime.split(' ').slice(1).join(' ');
3746
- let displayTime = timePart;
3747
- if (!hour12_${props.id.replace(/-/g, '_')}) {
3748
- // Convert to 24h if needed
3749
- const d = new Date('2000-01-01 ' + timePart);
3750
- displayTime = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
3751
- }
3752
- return { city, time: displayTime, ok: true };
3753
- } catch (e) {
3754
- return { city: loc, time: '—', ok: false };
3755
- }
3756
- }));
3768
+ const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: hour12_${props.id.replace(/-/g, '_')} });
3769
+ return { city: loc, time: fmt.format(now) };
3770
+ } catch(e) { return { city: loc, time: '—' }; }
3771
+ });
3757
3772
  container.innerHTML = results.map(r =>
3758
3773
  '<div class="tz-row"><span class="tz-city">' + r.city + '</span><span class="tz-time">' + r.time + '</span></div>'
3759
3774
  ).join('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobsterboard",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Self-hosted drag-and-drop dashboard builder with 50 widgets, template gallery, and custom pages. Works standalone or with OpenClaw.",
5
5
  "keywords": [
6
6
  "dashboard",
package/server.cjs CHANGED
@@ -1241,6 +1241,42 @@ async function fetchCursorUsage() {
1241
1241
  }
1242
1242
  }
1243
1243
 
1244
+ // Refresh Gemini CLI OAuth token.
1245
+ // These client credentials are intentionally public — gemini-cli is an installed
1246
+ // application and Google's own guidance permits embedding them in the source:
1247
+ // https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
1248
+ const GEMINI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
1249
+ const GEMINI_CLIENT_SECRET = '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';
1250
+
1251
+ async function refreshGeminiToken(credsPath, creds) {
1252
+ if (!creds.refresh_token) return { error: 'No refresh token available.' };
1253
+ try {
1254
+ const resp = await fetch('https://oauth2.googleapis.com/token', {
1255
+ method: 'POST',
1256
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1257
+ body: new URLSearchParams({
1258
+ grant_type: 'refresh_token',
1259
+ refresh_token: creds.refresh_token,
1260
+ client_id: GEMINI_CLIENT_ID,
1261
+ client_secret: GEMINI_CLIENT_SECRET,
1262
+ }),
1263
+ });
1264
+ if (!resp.ok) return { error: 'Refresh failed (HTTP ' + resp.status + ')' };
1265
+ const data = await resp.json();
1266
+ const updated = {
1267
+ ...creds,
1268
+ access_token: data.access_token,
1269
+ expiry_date: Date.now() + (data.expires_in * 1000),
1270
+ };
1271
+ if (data.refresh_token) updated.refresh_token = data.refresh_token;
1272
+ if (data.id_token) updated.id_token = data.id_token;
1273
+ try { fs.writeFileSync(credsPath, JSON.stringify(updated, null, 2)); } catch (_) {}
1274
+ return { accessToken: data.access_token, creds: updated };
1275
+ } catch (e) {
1276
+ return { error: e.message };
1277
+ }
1278
+ }
1279
+
1244
1280
  // Fetch Gemini usage
1245
1281
  async function fetchGeminiUsage() {
1246
1282
  const baseInfo = {
@@ -1248,34 +1284,53 @@ async function fetchGeminiUsage() {
1248
1284
  name: AI_PROVIDERS.gemini.name,
1249
1285
  icon: AI_PROVIDERS.gemini.icon,
1250
1286
  };
1251
-
1287
+
1252
1288
  const credsPath = AI_PROVIDERS.gemini.credPaths[0];
1253
1289
  if (!fs.existsSync(credsPath)) {
1254
1290
  return { ...baseInfo, error: 'Not logged in. Run `gemini auth` first.' };
1255
1291
  }
1256
-
1292
+
1257
1293
  let creds;
1258
1294
  try {
1259
1295
  creds = JSON.parse(fs.readFileSync(credsPath, 'utf8'));
1260
1296
  } catch (e) {
1261
1297
  return { ...baseInfo, error: 'Invalid credentials file.' };
1262
1298
  }
1263
-
1264
- if (!creds.access_token) {
1299
+
1300
+ if (!creds.access_token && !creds.refresh_token) {
1265
1301
  return { ...baseInfo, error: 'No access token found.' };
1266
1302
  }
1267
-
1303
+
1304
+ // Proactively refresh if the token is expired or expires within 5 minutes.
1305
+ // This handles the case where gemini-cli on another machine has rotated the
1306
+ // shared OAuth token, leaving the stored access_token stale.
1307
+ const FIVE_MINUTES = 5 * 60 * 1000;
1308
+ if (!creds.access_token || (creds.expiry_date && Date.now() >= creds.expiry_date - FIVE_MINUTES)) {
1309
+ const refreshed = await refreshGeminiToken(credsPath, creds);
1310
+ if (refreshed.error) return { ...baseInfo, error: 'Session expired. Run `gemini auth` to re-auth.' };
1311
+ creds = refreshed.creds;
1312
+ }
1313
+
1314
+ const callQuotaApi = (token) => fetch('https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota', {
1315
+ method: 'POST',
1316
+ headers: {
1317
+ 'Authorization': `Bearer ${token}`,
1318
+ 'Content-Type': 'application/json',
1319
+ },
1320
+ body: '{}',
1321
+ });
1322
+
1268
1323
  try {
1269
- // Get user quota
1270
- const resp = await fetch('https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota', {
1271
- method: 'POST',
1272
- headers: {
1273
- 'Authorization': `Bearer ${creds.access_token}`,
1274
- 'Content-Type': 'application/json',
1275
- },
1276
- body: '{}',
1277
- });
1278
-
1324
+ let resp = await callQuotaApi(creds.access_token);
1325
+
1326
+ // On auth failure, attempt a token refresh and retry once before giving up.
1327
+ if (resp.status === 401 || resp.status === 403) {
1328
+ const refreshed = await refreshGeminiToken(credsPath, creds);
1329
+ if (refreshed.error) return { ...baseInfo, error: 'Session expired. Run `gemini auth` to re-auth.' };
1330
+ creds = refreshed.creds;
1331
+ resp = await callQuotaApi(creds.access_token);
1332
+ }
1333
+
1279
1334
  if (!resp.ok) {
1280
1335
  if (resp.status === 401 || resp.status === 403) {
1281
1336
  return { ...baseInfo, error: 'Session expired. Run `gemini auth` to re-auth.' };
@@ -1289,16 +1344,16 @@ async function fetchGeminiUsage() {
1289
1344
  // Parse quota buckets (API returns 'buckets' with 'remainingFraction')
1290
1345
  const buckets = data.buckets || data.quotaBuckets || [];
1291
1346
 
1292
- // Filter for interesting models and REQUESTS type
1293
- const interestingModels = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'];
1347
+ // Track all non-Vertex Gemini request buckets instead of a hardcoded
1348
+ // 2.x allowlist so new Gemini CLI quota windows surface automatically.
1294
1349
  const seen = new Set();
1295
1350
 
1296
1351
  for (const bucket of buckets) {
1297
1352
  if (bucket.tokenType !== 'REQUESTS') continue;
1298
1353
  // Skip vertex variants
1299
1354
  if (bucket.modelId?.includes('_vertex')) continue;
1300
- // Only show interesting models
1301
- if (!interestingModels.some(m => bucket.modelId?.startsWith(m))) continue;
1355
+ // Only show Gemini model buckets
1356
+ if (!bucket.modelId?.startsWith('gemini-')) continue;
1302
1357
  // Dedupe
1303
1358
  if (seen.has(bucket.modelId)) continue;
1304
1359
  seen.add(bucket.modelId);
@@ -1313,6 +1368,8 @@ async function fetchGeminiUsage() {
1313
1368
  });
1314
1369
  }
1315
1370
 
1371
+ metrics.sort((a, b) => String(a.label || '').localeCompare(String(b.label || '')));
1372
+
1316
1373
  return {
1317
1374
  ...baseInfo,
1318
1375
  plan: 'Gemini CLI',