simplemdg-dev-cli 2.5.0 → 2.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.
Files changed (69) hide show
  1. package/README.md +33 -0
  2. package/USER_GUIDE.md +58 -1
  3. package/dist/commands/cache.command.d.ts +2 -0
  4. package/dist/commands/cache.command.js +129 -0
  5. package/dist/commands/cache.command.js.map +1 -0
  6. package/dist/commands/cf.command.js +201 -122
  7. package/dist/commands/cf.command.js.map +1 -1
  8. package/dist/commands/gitlab.command.js +33 -23
  9. package/dist/commands/gitlab.command.js.map +1 -1
  10. package/dist/core/cache/smart-cache-events.d.ts +3 -0
  11. package/dist/core/cache/smart-cache-events.js +20 -0
  12. package/dist/core/cache/smart-cache-events.js.map +1 -0
  13. package/dist/core/cache/smart-cache-manager.d.ts +20 -0
  14. package/dist/core/cache/smart-cache-manager.js +148 -0
  15. package/dist/core/cache/smart-cache-manager.js.map +1 -0
  16. package/dist/core/cache/smart-cache-store.d.ts +8 -0
  17. package/dist/core/cache/smart-cache-store.js +74 -0
  18. package/dist/core/cache/smart-cache-store.js.map +1 -0
  19. package/dist/core/cache/smart-cache.d.ts +18 -0
  20. package/dist/core/cache/smart-cache.js +117 -0
  21. package/dist/core/cache/smart-cache.js.map +1 -0
  22. package/dist/core/cache/smart-cache.types.d.ts +62 -0
  23. package/dist/core/cache/smart-cache.types.js +17 -0
  24. package/dist/core/cache/smart-cache.types.js.map +1 -0
  25. package/dist/core/cf/cf-target-cache.d.ts +7 -0
  26. package/dist/core/cf/cf-target-cache.js +58 -0
  27. package/dist/core/cf/cf-target-cache.js.map +1 -0
  28. package/dist/core/cf/cf-target.types.d.ts +11 -0
  29. package/dist/core/cf/cf-target.types.js +11 -0
  30. package/dist/core/cf/cf-target.types.js.map +1 -0
  31. package/dist/core/db/db-studio-client.d.ts +1 -1
  32. package/dist/core/db/db-studio-client.js +250 -55
  33. package/dist/core/db/db-studio-client.js.map +1 -1
  34. package/dist/core/db/db-studio-server.js +171 -0
  35. package/dist/core/db/db-studio-server.js.map +1 -1
  36. package/dist/core/db/db-studio-styles.d.ts +1 -1
  37. package/dist/core/db/db-studio-styles.js +63 -0
  38. package/dist/core/db/db-studio-styles.js.map +1 -1
  39. package/dist/core/db/db-types.d.ts +54 -0
  40. package/dist/core/db/studio/sql-formatter.d.ts +25 -0
  41. package/dist/core/db/studio/sql-formatter.js +139 -0
  42. package/dist/core/db/studio/sql-formatter.js.map +1 -0
  43. package/dist/core/db/studio/studio-settings.d.ts +4 -0
  44. package/dist/core/db/studio/studio-settings.js +39 -0
  45. package/dist/core/db/studio/studio-settings.js.map +1 -0
  46. package/dist/core/db/studio/workspace-cache.d.ts +3 -0
  47. package/dist/core/db/studio/workspace-cache.js +51 -0
  48. package/dist/core/db/studio/workspace-cache.js.map +1 -0
  49. package/dist/index.js +3 -1
  50. package/dist/index.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/commands/cache.command.ts +159 -0
  53. package/src/commands/cf.command.ts +232 -129
  54. package/src/commands/gitlab.command.ts +37 -21
  55. package/src/core/cache/smart-cache-events.ts +20 -0
  56. package/src/core/cache/smart-cache-manager.ts +169 -0
  57. package/src/core/cache/smart-cache-store.ts +83 -0
  58. package/src/core/cache/smart-cache.ts +97 -0
  59. package/src/core/cache/smart-cache.types.ts +79 -0
  60. package/src/core/cf/cf-target-cache.ts +61 -0
  61. package/src/core/cf/cf-target.types.ts +17 -0
  62. package/src/core/db/db-studio-client.ts +250 -55
  63. package/src/core/db/db-studio-server.ts +156 -1
  64. package/src/core/db/db-studio-styles.ts +63 -0
  65. package/src/core/db/db-types.ts +61 -0
  66. package/src/core/db/studio/sql-formatter.ts +139 -0
  67. package/src/core/db/studio/studio-settings.ts +36 -0
  68. package/src/core/db/studio/workspace-cache.ts +51 -0
  69. package/src/index.ts +3 -1
@@ -34,6 +34,17 @@ import { runCommand, runCommandInherit } from "../core/process";
34
34
  import { resolveRepositoryPath } from "../core/repository";
35
35
  import { searchableSelectChoice, selectFromHistoryOrInput } from "../core/prompts";
36
36
  import { ensureExternalTool } from "../core/tooling";
37
+ import { smartRead, buildCfAppsKey, formatRelativeTime, DEFAULT_CACHE_TTL } from "../core/cache/smart-cache";
38
+ import {
39
+ addFavoriteTarget,
40
+ addRecentTarget,
41
+ isFavoriteTarget,
42
+ listFavoriteTargets,
43
+ listRecentTargets,
44
+ removeFavoriteTarget,
45
+ } from "../core/cf/cf-target-cache";
46
+ import { cfTargetKey, cfTargetLabel } from "../core/cf/cf-target.types";
47
+ import type { TCfTarget } from "../core/cf/cf-target.types";
37
48
  import type { TCloudFoundryApp, TCloudFoundryLoginProfile, TCloudFoundryOrgEntry, TCloudFoundryTarget } from "../core/types";
38
49
 
39
50
  type TCloudFoundryLoginOptions = {
@@ -1268,175 +1279,250 @@ async function getCloudFoundryOrganizationsAcrossRegions(options: { api?: string
1268
1279
  return fallbackEntries;
1269
1280
  }
1270
1281
 
1271
- async function runOrgCommand(options: TCloudFoundryOrgOptions): Promise<void> {
1272
- const target = await ensureCloudFoundrySessionFromCache();
1282
+ function orgEntryToTarget(entry: TCloudFoundryOrgEntry): TCfTarget {
1283
+ return { region: entry.region, apiEndpoint: entry.apiEndpoint, org: entry.org, space: "", lastRefreshedAt: entry.updatedAt };
1284
+ }
1273
1285
 
1274
- const action = options.list
1275
- ? "list"
1276
- : options.switch
1277
- ? "switch"
1278
- : await searchableSelectChoice({
1279
- message: "What do you want to do with CF org?",
1280
- choices: [
1281
- { title: "List orgs across regions", value: "list" },
1282
- { title: "Switch org across regions", value: "switch" },
1283
- ],
1284
- validateCustomValue: (value) => {
1285
- const normalizedValue = value.trim().toLowerCase();
1286
- return normalizedValue === "list" || normalizedValue === "switch"
1287
- ? true
1288
- : "Type list or switch, or select one option.";
1289
- },
1290
- });
1286
+ function dedupeTargets(targets: TCfTarget[]): TCfTarget[] {
1287
+ const seen = new Set<string>();
1288
+ const result: TCfTarget[] = [];
1291
1289
 
1292
- const organizationEntries = await getCloudFoundryOrganizationsAcrossRegions({
1293
- api: options.api,
1294
- refresh: options.refresh,
1290
+ for (const target of targets) {
1291
+ const key = cfTargetKey(target);
1292
+ if (seen.has(key)) continue;
1293
+ seen.add(key);
1294
+ result.push(target);
1295
+ }
1296
+
1297
+ return result;
1298
+ }
1299
+
1300
+ async function switchToCfTarget(target: TCfTarget, options: { space?: string }): Promise<void> {
1301
+ const apiEndpoint = target.apiEndpoint;
1302
+
1303
+ if (!apiEndpoint) {
1304
+ throw new Error("Cannot determine CF API endpoint for the selected target.");
1305
+ }
1306
+
1307
+ const region = target.region || inferCloudFoundryRegionFromApiEndpoint(apiEndpoint);
1308
+ const authenticatedProfile = await ensureCloudFoundryAuthenticatedForApiEndpoint({
1309
+ apiEndpoint,
1310
+ preferredOrg: target.org,
1311
+ preferredSpace: options.space ?? target.space,
1312
+ reason: "switch-target",
1295
1313
  });
1296
- const organizationRegionCount = new Set(organizationEntries.map((entry) => entry.region)).size;
1297
- const latestTarget = await readCloudFoundryTarget();
1298
1314
 
1299
- if (action === "list") {
1300
- printTarget(latestTarget);
1301
- console.log("");
1315
+ const orgExitCode = await targetCloudFoundryOrg(target.org);
1316
+
1317
+ if (orgExitCode !== 0) {
1318
+ console.log(chalk.yellow("Cannot switch to this org after automatic authentication."));
1319
+ console.log(chalk.gray("Run smdg cf login, save the password, then try again."));
1320
+ process.exitCode = orgExitCode;
1321
+ return;
1322
+ }
1323
+
1324
+ let space = options.space?.trim() || target.space?.trim() || "";
1302
1325
 
1303
- if (!organizationEntries.length) {
1304
- console.log(chalk.yellow("No orgs found for current CF user. Run smdg cf login, save the password, then run smdg cf org again."));
1326
+ if (!space) {
1327
+ const spaces = await listCloudFoundrySpaces().catch(() => [] as string[]);
1328
+ const currentAfter = await readCloudFoundryTarget();
1329
+ const preferred = currentAfter.space || (spaces.includes("app") ? "app" : spaces[0]);
1330
+ space = spaces.length
1331
+ ? await searchableSelectChoice({
1332
+ message: "Select CF space",
1333
+ choices: [
1334
+ ...spaces.filter((s) => s === preferred).map((s) => ({ title: `${s} ${chalk.gray("suggested")}`, value: s })),
1335
+ ...spaces.filter((s) => s !== preferred).map((s) => ({ title: s, value: s })),
1336
+ ],
1337
+ validateCustomValue: validateRequired,
1338
+ customValueTitle: (value) => `Use typed CF space: ${value}`,
1339
+ })
1340
+ : await selectFromHistoryOrInput({ message: "Enter CF space", values: [], initialValue: "app" });
1341
+ }
1342
+
1343
+ if (space) {
1344
+ const spaceExitCode = await targetCloudFoundrySpace(space);
1345
+ if (spaceExitCode !== 0) {
1346
+ process.exitCode = spaceExitCode;
1305
1347
  return;
1306
1348
  }
1349
+ }
1307
1350
 
1308
- console.log(chalk.gray(`Showing ${organizationEntries.length} org(s) across ${organizationRegionCount} region(s).`));
1309
- console.log("");
1351
+ await addRecentTarget({ region, apiEndpoint, org: target.org, space });
1352
+
1353
+ if (authenticatedProfile?.password) {
1354
+ await rememberCloudFoundryLoginProfile({ ...authenticatedProfile, apiEndpoint, org: target.org, space, updatedAt: new Date().toISOString() });
1355
+ }
1356
+
1357
+ console.log(chalk.green("CF target switched."));
1358
+ printTarget(await readCloudFoundryTarget());
1310
1359
 
1311
- for (const entry of organizationEntries) {
1312
- const marker = entry.apiEndpoint === latestTarget.apiEndpoint && entry.org === latestTarget.org ? chalk.green("*") : " ";
1313
- console.log(`${marker} ${formatCloudFoundryOrgEntry(entry, latestTarget)}`);
1360
+ if (!(await isFavoriteTarget({ region, apiEndpoint, org: target.org, space }))) {
1361
+ const favorite = await prompts({ type: "confirm", name: "fav", message: "Mark this target as favorite?", initial: false });
1362
+ if (favorite.fav) {
1363
+ await addFavoriteTarget({ region, apiEndpoint, org: target.org, space });
1364
+ console.log(chalk.gray("Added to favorites."));
1314
1365
  }
1366
+ }
1367
+ }
1315
1368
 
1369
+ async function manageFavoriteTargets(favorites: TCfTarget[]): Promise<void> {
1370
+ if (!favorites.length) {
1371
+ console.log(chalk.gray("No favorites yet. Switch to a target and choose to favorite it."));
1316
1372
  return;
1317
1373
  }
1318
1374
 
1319
- let selectedEntry: TCloudFoundryOrgEntry | undefined;
1375
+ const selected = await searchableSelectChoice({
1376
+ message: "Favorites — select one to remove",
1377
+ choices: [
1378
+ ...favorites.map((target, index) => ({ title: `★ ${cfTargetLabel(target)}`, value: String(index) })),
1379
+ { title: "Cancel", value: "__cancel__" },
1380
+ ],
1381
+ allowCustomValue: false,
1382
+ });
1383
+
1384
+ if (selected === "__cancel__") {
1385
+ return;
1386
+ }
1320
1387
 
1321
- if (options.org?.trim()) {
1322
- selectedEntry = organizationEntries.find((entry) => {
1323
- return entry.org === options.org?.trim() && (!options.api?.trim() || entry.apiEndpoint === options.api.trim());
1324
- }) ?? {
1325
- apiEndpoint: options.api?.trim() || latestTarget.apiEndpoint || "",
1326
- region: options.api?.trim()
1327
- ? inferCloudFoundryRegionFromApiEndpoint(options.api.trim())
1328
- : inferCloudFoundryRegionFromApiEndpoint(latestTarget.apiEndpoint ?? "current"),
1329
- org: options.org.trim(),
1330
- updatedAt: new Date().toISOString(),
1331
- };
1332
- } else {
1333
- if (!organizationEntries.length) {
1334
- console.log(chalk.yellow("No orgs were found across regions."));
1335
- console.log(chalk.gray("Run smdg cf login and save the password, then run smdg cf org --list --refresh."));
1336
- return;
1337
- }
1388
+ await removeFavoriteTarget(favorites[Number(selected)]);
1389
+ console.log(chalk.green("Removed from favorites."));
1390
+ }
1338
1391
 
1339
- const selectedIndex = await searchableSelectChoice({
1340
- message: `Search CF org across ${organizationRegionCount} region(s)`,
1341
- choices: organizationEntries.map((entry, index) => ({
1342
- title: formatCloudFoundryOrgEntry(entry, latestTarget),
1343
- value: String(index),
1344
- })),
1345
- validateCustomValue: validateRequired,
1346
- customValueTitle: (value) => `Use typed CF org in current region: ${value}`,
1347
- });
1392
+ function printTargetSections(favorites: TCfTarget[], recent: TCfTarget[], orgEntries: TCloudFoundryOrgEntry[], current: TCloudFoundryTarget): void {
1393
+ console.log(chalk.bold("CF Target Switcher"));
1394
+ console.log("");
1348
1395
 
1349
- selectedEntry = organizationEntries[Number(selectedIndex)] ?? {
1350
- apiEndpoint: latestTarget.apiEndpoint ?? "",
1351
- region: inferCloudFoundryRegionFromApiEndpoint(latestTarget.apiEndpoint ?? "current"),
1352
- org: selectedIndex,
1353
- updatedAt: new Date().toISOString(),
1354
- };
1396
+ if (favorites.length) {
1397
+ console.log(chalk.yellow("Favorites"));
1398
+ favorites.forEach((target) => console.log(` ${chalk.yellow("")} ${cfTargetLabel(target)}`));
1399
+ console.log("");
1355
1400
  }
1356
1401
 
1357
- if (!selectedEntry.apiEndpoint) {
1358
- throw new Error("Cannot determine CF API endpoint for selected org.");
1402
+ if (recent.length) {
1403
+ console.log(chalk.cyan("Recent"));
1404
+ recent.forEach((target) => console.log(` ${chalk.gray("◷")} ${cfTargetLabel(target)}`));
1405
+ console.log("");
1359
1406
  }
1360
1407
 
1361
- const authenticatedProfile = await ensureCloudFoundryAuthenticatedForApiEndpoint({
1362
- apiEndpoint: selectedEntry.apiEndpoint,
1363
- preferredOrg: selectedEntry.org,
1364
- preferredSpace: options.space,
1365
- reason: "switch-org",
1366
- });
1408
+ console.log(chalk.gray(`All Targets (${orgEntries.length})`));
1409
+ for (const entry of orgEntries) {
1410
+ const marker = entry.apiEndpoint === current.apiEndpoint && entry.org === current.org ? chalk.green("*") : " ";
1411
+ console.log(`${marker} ${entry.region} / ${entry.org}`);
1412
+ }
1413
+ }
1367
1414
 
1368
- const orgExitCode = await targetCloudFoundryOrg(selectedEntry.org);
1415
+ async function runOrgCommand(options: TCloudFoundryOrgOptions): Promise<void> {
1416
+ const latestTarget = await readCloudFoundryTarget();
1417
+ const favorites = await listFavoriteTargets();
1418
+ const recent = await listRecentTargets();
1369
1419
 
1370
- if (orgExitCode !== 0) {
1371
- console.log(chalk.yellow("Cannot switch to this org after automatic authentication."));
1372
- console.log(chalk.gray("Run smdg cf login, save the password, then try again."));
1373
- process.exitCode = orgExitCode;
1420
+ // Direct switch via flags.
1421
+ if (options.org?.trim()) {
1422
+ await ensureExternalTool("cf");
1423
+ const apiEndpoint = options.api?.trim() || latestTarget.apiEndpoint || "";
1424
+ const region = inferCloudFoundryRegionFromApiEndpoint(apiEndpoint || "current");
1425
+ await switchToCfTarget({ region, apiEndpoint, org: options.org.trim(), space: options.space?.trim() || "" }, { space: options.space });
1374
1426
  return;
1375
1427
  }
1376
1428
 
1377
- const spaces = selectedEntry.spaces?.length ? selectedEntry.spaces : await listCloudFoundrySpaces();
1378
- const currentTargetAfterOrgSwitch = await readCloudFoundryTarget();
1379
- const preferredSpace = options.space?.trim() || currentTargetAfterOrgSwitch.space || (spaces.includes("app") ? "app" : spaces[0]);
1429
+ const action = options.list
1430
+ ? "list"
1431
+ : options.switch
1432
+ ? "switch"
1433
+ : await searchableSelectChoice({
1434
+ message: "CF target switcher",
1435
+ choices: [
1436
+ { title: "Switch to a target (favorites, recent, all)", value: "switch" },
1437
+ { title: "Refresh all regions", value: "refresh" },
1438
+ { title: "List targets", value: "list" },
1439
+ { title: "Manage favorites", value: "favorites" },
1440
+ { title: "Show current target", value: "current" },
1441
+ ],
1442
+ allowCustomValue: false,
1443
+ });
1380
1444
 
1381
- const space = options.space?.trim() || await searchableSelectChoice({
1382
- message: "Select CF space",
1383
- choices: [
1384
- ...spaces
1385
- .filter((spaceName) => spaceName === preferredSpace)
1386
- .map((spaceName) => ({ title: `${spaceName} ${spaceName === currentTargetAfterOrgSwitch.space ? chalk.gray("current") : chalk.gray("suggested")}`, value: spaceName })),
1387
- ...spaces
1388
- .filter((spaceName) => spaceName !== preferredSpace)
1389
- .map((spaceName) => ({ title: spaceName, value: spaceName })),
1390
- ],
1391
- validateCustomValue: validateRequired,
1392
- customValueTitle: (value) => `Use typed CF space: ${value}`,
1393
- });
1445
+ if (action === "current") {
1446
+ printTarget(latestTarget);
1447
+ return;
1448
+ }
1394
1449
 
1395
- if (space) {
1396
- const spaceExitCode = await targetCloudFoundrySpace(space);
1450
+ if (action === "favorites") {
1451
+ await manageFavoriteTargets(favorites);
1452
+ return;
1453
+ }
1397
1454
 
1398
- if (spaceExitCode !== 0) {
1399
- process.exitCode = spaceExitCode;
1400
- return;
1401
- }
1455
+ let orgEntries: TCloudFoundryOrgEntry[];
1456
+
1457
+ if (action === "refresh" || options.refresh) {
1458
+ await ensureExternalTool("cf");
1459
+ console.log(chalk.gray("Refreshing CF orgs across regions..."));
1460
+ orgEntries = await getCloudFoundryOrganizationsAcrossRegions({ api: options.api, refresh: true });
1461
+ console.log(chalk.green(`Refresh completed · ${orgEntries.length} target(s) found.`));
1462
+ console.log("");
1463
+ } else {
1464
+ const cache = await readCache();
1465
+ orgEntries = cache.cloudFoundry.orgsAcrossRegions ?? [];
1402
1466
  }
1403
1467
 
1404
- if (authenticatedProfile?.password) {
1405
- await rememberCloudFoundryLoginProfile({
1406
- ...authenticatedProfile,
1407
- apiEndpoint: selectedEntry.apiEndpoint,
1408
- org: selectedEntry.org,
1409
- space,
1410
- updatedAt: new Date().toISOString(),
1411
- });
1468
+ if (action === "list") {
1469
+ printTargetSections(favorites, recent, orgEntries, latestTarget);
1470
+ return;
1471
+ }
1472
+
1473
+ const allTargets = orgEntries.map(orgEntryToTarget);
1474
+ const recentKeys = new Set(recent.map((target) => cfTargetKey(target)));
1475
+ const combined = dedupeTargets([...favorites, ...recent, ...allTargets]);
1476
+
1477
+ if (!combined.length) {
1478
+ console.log(chalk.yellow("No cached targets yet."));
1479
+ console.log(chalk.gray("Choose 'Refresh all regions', or run smdg cf login and save the password."));
1480
+ return;
1412
1481
  }
1413
1482
 
1414
- const switchedTarget = await readCloudFoundryTarget();
1415
- console.log(chalk.green("CF org/space switched."));
1416
- printTarget(switchedTarget);
1483
+ const selectedIndex = await searchableSelectChoice({
1484
+ message: `Select CF target (${favorites.length} favorite · ${recent.length} recent · ${allTargets.length} all)`,
1485
+ choices: combined.map((target, index) => {
1486
+ const marker = target.isFavorite ? chalk.yellow("★ ") : recentKeys.has(cfTargetKey(target)) ? chalk.gray("◷ ") : " ";
1487
+ return { title: `${marker}${cfTargetLabel(target)}`, value: String(index) };
1488
+ }),
1489
+ validateCustomValue: validateRequired,
1490
+ customValueTitle: (value) => `Use typed org in current region: ${value}`,
1491
+ });
1492
+
1493
+ const chosen = combined[Number(selectedIndex)] ?? {
1494
+ region: inferCloudFoundryRegionFromApiEndpoint(latestTarget.apiEndpoint ?? "current"),
1495
+ apiEndpoint: latestTarget.apiEndpoint ?? "",
1496
+ org: selectedIndex,
1497
+ space: "",
1498
+ };
1499
+
1500
+ await ensureExternalTool("cf");
1501
+ await switchToCfTarget(chosen, { space: options.space });
1417
1502
  }
1418
1503
 
1419
1504
  async function runAppsCommand(options: TCloudFoundryAppsOptions): Promise<void> {
1420
- const target = await readCloudFoundryTarget();
1421
- const targetKey = buildCloudFoundryTargetKey(target);
1422
- const cache = await readCache();
1423
- const cachedEntry = cache.cloudFoundry.appListsByTarget[targetKey];
1424
-
1505
+ const target = await ensureCloudFoundrySessionFromCache();
1425
1506
  printTarget(target);
1426
1507
  console.log("");
1427
1508
 
1428
- const shouldUseCache = !options.refresh && Boolean(cachedEntry?.apps.length);
1429
- const apps = await getAppsWithCache({
1430
- refresh: options.refresh,
1431
- startBackgroundRefresh: shouldUseCache,
1509
+ const region = target.apiEndpoint ? inferCloudFoundryRegionFromApiEndpoint(target.apiEndpoint) : "current";
1510
+ const cacheKey = buildCfAppsKey(region, target.org ?? "?", target.space ?? "?");
1511
+ const targetLabel = `${region} / ${target.org ?? "?"} / ${target.space ?? "?"}`;
1512
+
1513
+ const result = await smartRead<TCloudFoundryApp[]>({
1514
+ namespace: "cf-apps",
1515
+ key: cacheKey,
1516
+ ttlMs: DEFAULT_CACHE_TTL.cfApps,
1517
+ mode: options.refresh ? "network-only" : "stale-while-revalidate",
1518
+ fetcher: listCloudFoundryApps,
1432
1519
  });
1433
1520
 
1434
- if (shouldUseCache && cachedEntry) {
1435
- console.log(chalk.gray(`Using cached cf apps: ${cachedEntry.apps.length} apps, updated at ${cachedEntry.updatedAt}`));
1436
- console.log(chalk.gray("Refreshing cf apps cache in background. Use --refresh when you want to wait for fresh data."));
1437
- console.log("");
1438
- } else if (options.refresh) {
1439
- console.log(chalk.green(`Refreshed cf apps cache for ${targetKey}.`));
1521
+ if (result.fromCache) {
1522
+ console.log(chalk.gray(`Using cached apps for ${targetLabel} from ${formatRelativeTime(result.updatedAt)}.`));
1523
+ if (result.isRefreshing) {
1524
+ console.log(chalk.gray("Refreshing apps in background..."));
1525
+ }
1440
1526
  console.log("");
1441
1527
  }
1442
1528
 
@@ -1446,9 +1532,26 @@ async function runAppsCommand(options: TCloudFoundryAppsOptions): Promise<void>
1446
1532
  return;
1447
1533
  }
1448
1534
 
1449
- for (const app of apps) {
1535
+ for (const app of result.data) {
1450
1536
  console.log([app.name, app.requestedState, app.processes, app.routes].filter(Boolean).join(" | "));
1451
1537
  }
1538
+
1539
+ // Mirror the smart-cache apps into the legacy per-target cache used by
1540
+ // resolveAppSelection so bind/debug/logs stay in sync.
1541
+ await rememberCloudFoundryApps(buildCloudFoundryTargetKey(target), result.data).catch(() => undefined);
1542
+
1543
+ if (result.refreshPromise) {
1544
+ try {
1545
+ const fresh = await result.refreshPromise;
1546
+ await rememberCloudFoundryApps(buildCloudFoundryTargetKey(target), fresh).catch(() => undefined);
1547
+ console.log("");
1548
+ console.log(chalk.gray("Background refresh completed. Cache updated."));
1549
+ } catch (error) {
1550
+ console.log("");
1551
+ console.log(chalk.yellow(`Background refresh failed: ${error instanceof Error ? error.message : String(error)}`));
1552
+ console.log(chalk.gray("Showing cached apps. Run smdg cf login if your session expired."));
1553
+ }
1554
+ }
1452
1555
  }
1453
1556
 
1454
1557
  async function runAppsCacheRefreshCommand(): Promise<void> {
@@ -7,6 +7,7 @@ import prompts from "prompts";
7
7
  import chalk from "chalk";
8
8
  import { Command } from "commander";
9
9
  import { searchableSelectChoice } from "../core/prompts";
10
+ import { smartRead, buildGitLabGroupsKey, buildGitLabProjectsKey, formatRelativeTime, DEFAULT_CACHE_TTL } from "../core/cache/smart-cache";
10
11
 
11
12
  const GITLAB_CACHE_DIR = path.join(os.homedir(), ".simplemdg");
12
13
  const GITLAB_CACHE_FILE = path.join(GITLAB_CACHE_DIR, "gitlab.json");
@@ -186,15 +187,23 @@ async function runLoginFlow(): Promise<TGitLabAuth> {
186
187
  return auth;
187
188
  }
188
189
 
189
- async function listRootGroups(auth: TGitLabAuth, refresh: boolean): Promise<TGitLabGroup[]> {
190
- const cache = await readGitLabCache();
191
- const cached = cache.groupsByBaseUrl[auth.baseUrl];
192
- if (!refresh && cached?.groups?.length) return cached.groups;
193
- console.log(chalk.gray(`Scanning GitLab root groups from ${auth.baseUrl}...`));
194
- const groups = await gitlabFetchAll<TGitLabGroup>(auth, "/groups", { min_access_level: "10", top_level_only: "true", all_available: "false", order_by: "name", sort: "asc" });
195
- cache.groupsByBaseUrl[auth.baseUrl] = { updatedAt: new Date().toISOString(), groups };
196
- await writeGitLabCache(cache);
197
- return groups;
190
+ async function listRootGroups(auth: TGitLabAuth, refresh: boolean, notify = false): Promise<TGitLabGroup[]> {
191
+ const result = await smartRead<TGitLabGroup[]>({
192
+ namespace: "gitlab-groups",
193
+ key: buildGitLabGroupsKey(auth.baseUrl, auth.username),
194
+ ttlMs: DEFAULT_CACHE_TTL.gitlabGroups,
195
+ mode: refresh ? "network-only" : "stale-while-revalidate",
196
+ fetcher: () => gitlabFetchAll<TGitLabGroup>(auth, "/groups", { min_access_level: "10", top_level_only: "true", all_available: "false", order_by: "name", sort: "asc" }),
197
+ });
198
+
199
+ if (notify && result.fromCache) {
200
+ console.log(chalk.gray(`Using ${result.data.length} cached GitLab groups from ${formatRelativeTime(result.updatedAt)}.${result.isRefreshing ? " Refreshing in background..." : ""}`));
201
+ }
202
+ if (notify && result.refreshPromise) {
203
+ result.refreshPromise.then(() => console.log(chalk.gray("GitLab groups cache updated."))).catch(() => console.log(chalk.yellow("GitLab groups refresh failed; using cached list.")));
204
+ }
205
+
206
+ return result.data;
198
207
  }
199
208
 
200
209
  async function askGroup(auth: TGitLabAuth, refresh?: boolean): Promise<TGitLabGroup> {
@@ -210,17 +219,24 @@ async function askGroup(auth: TGitLabAuth, refresh?: boolean): Promise<TGitLabGr
210
219
  return group;
211
220
  }
212
221
 
213
- async function listProjects(auth: TGitLabAuth, group: TGitLabGroup, refresh: boolean): Promise<TGitLabProject[]> {
214
- const cache = await readGitLabCache();
215
- const key = makeGroupCacheKey(auth.baseUrl, group.id);
216
- const cached = cache.projectsByGroup[key];
217
- if (!refresh && cached?.projects?.length) return cached.projects;
218
- console.log(chalk.gray(`Scanning projects in ${group.full_path}...`));
222
+ async function listProjects(auth: TGitLabAuth, group: TGitLabGroup, refresh: boolean, notify = false): Promise<TGitLabProject[]> {
219
223
  const encodedId = encodeURIComponent(String(group.id));
220
- const projects = await gitlabFetchAll<TGitLabProject>(auth, `/groups/${encodedId}/projects`, { include_subgroups: "true", archived: "false", order_by: "path", sort: "asc" });
221
- cache.projectsByGroup[key] = { updatedAt: new Date().toISOString(), projects };
222
- await writeGitLabCache(cache);
223
- return projects;
224
+ const result = await smartRead<TGitLabProject[]>({
225
+ namespace: "gitlab-projects",
226
+ key: buildGitLabProjectsKey(auth.baseUrl, group.id),
227
+ ttlMs: DEFAULT_CACHE_TTL.gitlabProjects,
228
+ mode: refresh ? "network-only" : "stale-while-revalidate",
229
+ fetcher: () => gitlabFetchAll<TGitLabProject>(auth, `/groups/${encodedId}/projects`, { include_subgroups: "true", archived: "false", order_by: "path", sort: "asc" }),
230
+ });
231
+
232
+ if (notify && result.fromCache) {
233
+ console.log(chalk.gray(`Using ${result.data.length} cached projects in ${group.full_path} from ${formatRelativeTime(result.updatedAt)}.${result.isRefreshing ? " Refreshing in background..." : ""}`));
234
+ }
235
+ if (notify && result.refreshPromise) {
236
+ result.refreshPromise.then(() => console.log(chalk.gray("GitLab projects cache updated."))).catch(() => console.log(chalk.yellow("GitLab projects refresh failed; using cached list.")));
237
+ }
238
+
239
+ return result.data;
224
240
  }
225
241
 
226
242
  function localProjectPath(destination: string, project: TGitLabProject): string {
@@ -359,13 +375,13 @@ export function registerGitLabCommands(program: Command): void {
359
375
  gitlab.command("logout").description("Remove cached GitLab login").action(async () => { const cache = await readGitLabCache(); cache.instances = []; await writeGitLabCache(cache); console.log("GitLab login cache cleared."); });
360
376
  gitlab.command("groups").description("List GitLab root groups").option("--refresh", "Refresh from API").action(async (options: { refresh?: boolean }) => {
361
377
  const auth = await askAuth();
362
- const groups = await listRootGroups(auth, !!options.refresh);
378
+ const groups = await listRootGroups(auth, !!options.refresh, true);
363
379
  for (const group of groups) console.log(`${group.full_path} · #${group.id} · ${group.visibility ?? ""}`);
364
380
  });
365
381
  gitlab.command("projects").description("List projects in a GitLab root group").option("--refresh", "Refresh from API").action(async (options: { refresh?: boolean }) => {
366
382
  const auth = await askAuth();
367
383
  const group = await askGroup(auth, options.refresh);
368
- const projects = await listProjects(auth, group, !!options.refresh);
384
+ const projects = await listProjects(auth, group, !!options.refresh, true);
369
385
  for (const project of projects) console.log(`${project.path_with_namespace} · #${project.id}`);
370
386
  });
371
387
  gitlab.command("sync").alias("clone").description("Clone or update GitLab projects without ghorg").option("--refresh", "Refresh groups/projects from API").action(runSync);
@@ -0,0 +1,20 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { TCacheEvent } from "./smart-cache.types";
3
+
4
+ /**
5
+ * Process-local event bus for Smart Cache background refreshes. The DB Studio
6
+ * server subscribes to this and forwards events to the browser over SSE.
7
+ */
8
+ const emitter = new EventEmitter();
9
+ emitter.setMaxListeners(50);
10
+
11
+ const EVENT_NAME = "cache-event";
12
+
13
+ export function emitCacheEvent(event: TCacheEvent): void {
14
+ emitter.emit(EVENT_NAME, event);
15
+ }
16
+
17
+ export function onCacheEvent(listener: (event: TCacheEvent) => void): () => void {
18
+ emitter.on(EVENT_NAME, listener);
19
+ return () => emitter.off(EVENT_NAME, listener);
20
+ }