@opendatalabs/connect 0.10.1 → 0.11.1

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 (41) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/auth.d.ts +112 -0
  3. package/dist/cli/auth.d.ts.map +1 -0
  4. package/dist/cli/auth.js +339 -0
  5. package/dist/cli/auth.js.map +1 -0
  6. package/dist/cli/index.d.ts +1 -0
  7. package/dist/cli/index.d.ts.map +1 -1
  8. package/dist/cli/index.js +648 -191
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/cli/queries.d.ts.map +1 -1
  11. package/dist/cli/queries.js +16 -9
  12. package/dist/cli/queries.js.map +1 -1
  13. package/dist/cli/render/connect-renderer.d.ts +5 -0
  14. package/dist/cli/render/connect-renderer.d.ts.map +1 -1
  15. package/dist/cli/render/connect-renderer.js +62 -58
  16. package/dist/cli/render/connect-renderer.js.map +1 -1
  17. package/dist/cli/render/flow-theme.d.ts +17 -0
  18. package/dist/cli/render/flow-theme.d.ts.map +1 -0
  19. package/dist/cli/render/flow-theme.js +26 -0
  20. package/dist/cli/render/flow-theme.js.map +1 -0
  21. package/dist/cli/render/index.d.ts +1 -0
  22. package/dist/cli/render/index.d.ts.map +1 -1
  23. package/dist/cli/render/index.js +1 -0
  24. package/dist/cli/render/index.js.map +1 -1
  25. package/dist/cli/render/theme.d.ts +1 -0
  26. package/dist/cli/render/theme.d.ts.map +1 -1
  27. package/dist/cli/render/theme.js +19 -3
  28. package/dist/cli/render/theme.js.map +1 -1
  29. package/dist/core/cli-types.d.ts +3 -0
  30. package/dist/core/cli-types.d.ts.map +1 -1
  31. package/dist/core/cli-types.js +1 -1
  32. package/dist/core/cli-types.js.map +1 -1
  33. package/dist/personal-server/client.d.ts +1 -1
  34. package/dist/personal-server/client.d.ts.map +1 -1
  35. package/dist/personal-server/client.js +31 -17
  36. package/dist/personal-server/client.js.map +1 -1
  37. package/dist/personal-server/index.d.ts +12 -2
  38. package/dist/personal-server/index.d.ts.map +1 -1
  39. package/dist/personal-server/index.js +52 -18
  40. package/dist/personal-server/index.js.map +1 -1
  41. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -26,14 +26,15 @@ const vanaPromptTheme = {
26
26
  },
27
27
  },
28
28
  };
29
- import { createConnectRenderer, createHumanRenderer, formatDisplayPath, formatRelativeTime, } from "./render/index.js";
29
+ import { createConnectRenderer, createHumanRenderer, createLoginRenderer, formatDisplayPath, formatRelativeTime, } from "./render/index.js";
30
30
  import { CliOutcomeStatus, migrateLegacyDataHome, getBrowserProfilesDir, getConnectorCacheDir, getLogsDir, getSessionsDir, getSourceResultPath, readCliState, readCliConfig, updateCliConfig, updateSourceState, } from "../core/index.js";
31
31
  import { fetchConnectorToCache, listAvailableSources, readCachedConnectorMetadata, } from "../connectors/registry.js";
32
- import { detectPersonalServerTarget, ingestResult, } from "../personal-server/index.js";
32
+ import { detectPersonalServerTarget, ingestResult, resolvePersonalServerAuthConfig, } from "../personal-server/index.js";
33
33
  import { findDataConnectorsDir, ManagedPlaywrightRuntime, } from "../runtime/index.js";
34
34
  import { listAvailableSkills, installSkill, readInstalledSkills, } from "../skills/index.js";
35
35
  import { queryStatus, querySources, queryDataList, queryDataShow, queryDoctor, } from "./queries.js";
36
36
  import { checkForUpdate, readUpdateCheck, isNewerVersion, } from "./update-check.js";
37
+ import { loadCredentials, saveCredentials, clearCredentials, isExpired, formatAddress, formatExpiresIn, getAuthTarget, resolvePersonalServerUrl, runDeviceCodeFlow, runSelfHostedLoginFlow, } from "./auth.js";
37
38
  function cleanDescription(desc) {
38
39
  return desc
39
40
  .replace(/ using Playwright browser automation\.?/i, ".")
@@ -73,6 +74,7 @@ export async function runCli(argv = process.argv) {
73
74
  .showSuggestionAfterError(true)
74
75
  .addHelpText("after", `
75
76
  Quick start:
77
+ vana login Log in to your Vana account
76
78
  vana connect Connect a source and collect data
77
79
  vana sources Browse available sources
78
80
  vana status Check system health
@@ -330,6 +332,19 @@ Examples:
330
332
  .action(async (scope) => {
331
333
  process.exitCode = await runServerData(scope, parsedOptions);
332
334
  });
335
+ program
336
+ .command("login")
337
+ .description("Log in to your Vana account or a self-hosted Personal Server")
338
+ .option("-s, --server <url>", "Self-hosted Personal Server URL")
339
+ .action(async (loginOptions) => {
340
+ process.exitCode = await runLogin(parsedOptions, loginOptions.server);
341
+ });
342
+ program
343
+ .command("logout")
344
+ .description("Log out and remove saved credentials")
345
+ .action(async () => {
346
+ process.exitCode = await runLogout(parsedOptions);
347
+ });
333
348
  program
334
349
  .command("mcp")
335
350
  .description("Start MCP server for agent integration")
@@ -834,6 +849,8 @@ async function runConnect(rawSource, options) {
834
849
  const ingestCompleted = ingestEvents.some((ingestEvent) => ingestEvent.type === "ingest-complete");
835
850
  const ingestPartial = ingestEvents.some((ingestEvent) => ingestEvent.type === "ingest-partial");
836
851
  const ingestFailedEvent = ingestEvents.find((ingestEvent) => ingestEvent.type === "ingest-failed");
852
+ const ingestSkippedUnavailable = ingestEvents.some((ingestEvent) => ingestEvent.type === "ingest-skipped" &&
853
+ ingestEvent.reason === "personal_server_unavailable");
837
854
  if (ingestCompleted) {
838
855
  finalStatus = CliOutcomeStatus.CONNECTED_AND_INGESTED;
839
856
  finalDataState = "ingested_personal_server";
@@ -848,6 +865,10 @@ async function runConnect(rawSource, options) {
848
865
  ingestFailureMessage =
849
866
  ingestFailedEvent.message ?? "Personal Server sync failed.";
850
867
  }
868
+ else if (ingestSkippedUnavailable) {
869
+ finalStatus = CliOutcomeStatus.CONNECTED_LOCAL_ONLY;
870
+ finalDataState = "ingest_unavailable";
871
+ }
851
872
  else {
852
873
  finalStatus = CliOutcomeStatus.CONNECTED_LOCAL_ONLY;
853
874
  finalDataState = "collected_local";
@@ -920,6 +941,9 @@ async function runConnect(rawSource, options) {
920
941
  else if (finalStatus === CliOutcomeStatus.CONNECTED_AND_INGESTED) {
921
942
  successSummary = `Collected your ${displayName} data and synced it to your Personal Server.`;
922
943
  }
944
+ else if (finalDataState === "ingest_unavailable") {
945
+ successSummary = `Collected your ${displayName} data. Personal Server sync is pending.`;
946
+ }
923
947
  else {
924
948
  successSummary = `Collected your ${displayName} data and saved it locally.`;
925
949
  }
@@ -932,6 +956,10 @@ async function runConnect(rawSource, options) {
932
956
  if (failedCount > 0 && storedCount > 0) {
933
957
  renderer?.detail(`Retry: vana server sync`);
934
958
  }
959
+ else if (finalDataState === "ingest_unavailable") {
960
+ renderer?.detail(`Pending sync will retry during scheduled collection.`);
961
+ renderer?.detail(`Retry now: vana server sync`);
962
+ }
935
963
  // Journey-aware next step
936
964
  const state = await readCliState();
937
965
  const connectedSourceCount = Object.values(state.sources ?? {}).filter((s) => hasCollectedData(s?.dataState)).length;
@@ -1151,10 +1179,19 @@ async function runStatus(options) {
1151
1179
  }
1152
1180
  }
1153
1181
  if (options.json) {
1182
+ const jsonAuthCreds = loadCredentials();
1154
1183
  const compactJson = {
1155
1184
  runtime: status.runtime,
1156
1185
  personalServer: status.personalServer,
1157
1186
  personalServerUrl: status.personalServerUrl,
1187
+ pendingSyncCount: status.pendingSyncCount ?? 0,
1188
+ auth: jsonAuthCreds
1189
+ ? {
1190
+ authenticated: !isExpired(jsonAuthCreds),
1191
+ address: jsonAuthCreds.account.address,
1192
+ expires_at: jsonAuthCreds.account.expires_at,
1193
+ }
1194
+ : { authenticated: false },
1158
1195
  sources: {
1159
1196
  connected: status.summary?.connectedCount ?? 0,
1160
1197
  needsAttention: status.summary?.needsAttentionCount ?? 0,
@@ -1177,115 +1214,55 @@ async function runStatus(options) {
1177
1214
  else {
1178
1215
  emit.keyValue("Personal Server", "not connected", "warning");
1179
1216
  }
1180
- const connectedCount = status.summary?.connectedCount ?? 0;
1181
- const attentionCount = status.summary?.needsAttentionCount ?? 0;
1217
+ // Auth state
1218
+ const authCreds = loadCredentials();
1219
+ if (authCreds && !isExpired(authCreds)) {
1220
+ emit.keyValue("Account", formatAddress(authCreds.account.address), "success");
1221
+ emit.keyValue("Auth", `Authenticated (expires in ${formatExpiresIn(authCreds.account.expires_at)})`, "success");
1222
+ }
1223
+ else {
1224
+ emit.keyValue("Account", "Not logged in", "muted");
1225
+ emit.keyValue("Auth", "Run `vana login` to authenticate", "muted");
1226
+ }
1227
+ const trackedSources = status.sources.filter(shouldDisplaySourceInStatus);
1228
+ const attentionSources = trackedSources
1229
+ .filter(isSourceAttention)
1230
+ .sort(compareAttentionPriority);
1231
+ const healthySources = trackedSources
1232
+ .filter((source) => !isSourceAttention(source))
1233
+ .sort(compareSourceStatusOrder);
1182
1234
  const sourceParts = [
1183
- connectedCount > 0 ? `${connectedCount} connected` : "none connected",
1184
- ...(connectedCount > 0 && attentionCount > 0
1185
- ? [`${attentionCount} need${attentionCount === 1 ? "s" : ""} attention`]
1235
+ healthySources.length > 0
1236
+ ? `${healthySources.length} healthy`
1237
+ : trackedSources.length > 0
1238
+ ? "none healthy"
1239
+ : "none connected",
1240
+ ...(attentionSources.length > 0
1241
+ ? [
1242
+ `${attentionSources.length} need${attentionSources.length === 1 ? "s" : ""} attention`,
1243
+ ]
1186
1244
  : []),
1187
1245
  ];
1188
- emit.keyValue("Sources", sourceParts.join(", "), attentionCount > 0 && connectedCount > 0
1246
+ emit.keyValue("Sources", sourceParts.join(", "), attentionSources.length > 0 && healthySources.length > 0
1189
1247
  ? "warning"
1190
- : connectedCount > 0
1248
+ : healthySources.length > 0
1191
1249
  ? "success"
1192
1250
  : "muted");
1193
- // Show per-source health when sources are connected
1194
- const connectedSources = Object.entries(state.sources).filter(([, stored]) => stored?.connectorInstalled &&
1195
- (stored.dataState === "collected_local" ||
1196
- stored.dataState === "ingested_personal_server" ||
1197
- stored.dataState === "ingest_failed" ||
1198
- stored.connectionHealth));
1199
- if (connectedSources.length > 0) {
1251
+ if ((status.pendingSyncCount ?? 0) > 0) {
1252
+ emit.keyValue("Pending sync", `${status.pendingSyncCount} dataset(s)`, "warning");
1253
+ }
1254
+ if (attentionSources.length > 0) {
1200
1255
  emit.blank();
1201
- let needsReauthSource = null;
1202
- for (const [sourceId, stored] of connectedSources) {
1203
- const health = stored?.connectionHealth ?? "healthy";
1204
- const displayName = displaySource(sourceId, sourceLabels);
1205
- const sourceStatus = status.sources.find((s) => s.source === sourceId);
1206
- const sourceOverdue = sourceStatus?.isOverdue ?? false;
1207
- const dataState = stored?.dataState;
1208
- // Show worst of connectionHealth and dataState
1209
- let healthLabel;
1210
- let healthTone;
1211
- if (dataState === "ingest_failed") {
1212
- healthLabel = "sync failed";
1213
- healthTone = "warning";
1214
- }
1215
- else {
1216
- healthTone = sourceOverdue ? "warning" : toneForHealth(health);
1217
- healthLabel = health === "needs_reauth" ? "needs login" : health;
1218
- }
1219
- const staleTag = sourceOverdue
1220
- ? ` ${emit.badge("stale", "warning")}`
1221
- : "";
1222
- const collectedAgo = stored?.lastCollectedAt
1223
- ? `collected ${formatRelativeTime(stored.lastCollectedAt)}`
1224
- : "";
1225
- emit.keyValue(` ${displayName}`, `${healthLabel}${staleTag} ${collectedAgo}`, healthTone);
1226
- if (dataState === "ingest_failed") {
1227
- const errMsg = stored?.lastError ?? "sync failed";
1228
- emit.detail(` \u21b3 ${errMsg}. Run \`vana connect ${sourceId}\``);
1229
- }
1230
- else if ((health === "needs_reauth" || health === "error") &&
1231
- stored?.connectionHealthReason) {
1232
- const msg = formatHealthMessage(stored.connectionHealthReason);
1233
- const ago = stored.connectionHealthChangedAt
1234
- ? ` (${formatRelativeTime(stored.connectionHealthChangedAt)})`
1235
- : "";
1236
- if (msg) {
1237
- emit.detail(` \u21b3 ${msg}${ago} Run \`vana connect ${sourceId}\``);
1238
- }
1239
- }
1240
- if (health === "needs_reauth" && !needsReauthSource) {
1241
- needsReauthSource = sourceId;
1242
- }
1243
- }
1244
- if (needsReauthSource) {
1245
- emit.blank();
1246
- emit.next(`vana connect ${needsReauthSource}`);
1247
- return 0;
1256
+ emit.section(formatCountLabel("Needs attention", attentionSources.length));
1257
+ for (const source of attentionSources) {
1258
+ emitHumanStatusSource(emit, source, sourceLabels);
1248
1259
  }
1249
1260
  }
1250
- // Show attention-needing sources that weren't already displayed above
1251
- const displayedSourceIds = new Set(connectedSources.map(([id]) => id));
1252
- const hiddenAttentionSources = status.sources.filter((s) => rankSourceStatus(s) <= 4 && !displayedSourceIds.has(s.source));
1253
- if (hiddenAttentionSources.length > 0) {
1254
- if (connectedSources.length === 0) {
1255
- emit.blank();
1256
- }
1257
- for (const source of hiddenAttentionSources) {
1258
- const displayName = displaySource(source.source, sourceLabels);
1259
- const presentation = getSourceStatusPresentation(source);
1260
- const collectedAgo = source.lastCollectedAt
1261
- ? `collected ${formatRelativeTime(source.lastCollectedAt)}`
1262
- : "";
1263
- emit.keyValue(` ${displayName}`, `${presentation.label}${collectedAgo ? ` ${collectedAgo}` : ""}`, presentation.tone);
1264
- // Show actionable detail line
1265
- if (source.lastRunOutcome === CliOutcomeStatus.INGEST_FAILED &&
1266
- source.ingestScopes) {
1267
- const failedScopes = source.ingestScopes.filter((s) => s.status === "failed");
1268
- for (const scope of failedScopes) {
1269
- const errMsg = scope.error ?? "sync failed";
1270
- emit.detail(` \u21b3 ${source.source}.${scope.scope}: ${errMsg}. Run \`vana connect ${source.source}\``);
1271
- }
1272
- if (failedScopes.length === 0) {
1273
- emit.detail(` \u21b3 Sync failed. Run \`vana connect ${source.source}\``);
1274
- }
1275
- }
1276
- else if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
1277
- emit.detail(` \u21b3 No connector available. Run \`vana sources\``);
1278
- }
1279
- else if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
1280
- const reason = source.lastError ?? "runtime error";
1281
- emit.detail(` \u21b3 ${reason}. Run \`vana connect ${source.source}\``);
1282
- }
1283
- else if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
1284
- emit.detail(` \u21b3 Requires interactive login. Run \`vana connect ${source.source}\``);
1285
- }
1286
- else if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
1287
- emit.detail(` \u21b3 Manual auth step required. Run \`vana connect ${source.source}\``);
1288
- }
1261
+ if (healthySources.length > 0) {
1262
+ emit.blank();
1263
+ emit.section(formatCountLabel("Healthy", healthySources.length));
1264
+ for (const source of healthySources) {
1265
+ emitHumanStatusSource(emit, source, sourceLabels);
1289
1266
  }
1290
1267
  }
1291
1268
  if (nextSteps.length > 0) {
@@ -1300,6 +1277,88 @@ async function runStatus(options) {
1300
1277
  }
1301
1278
  return 0;
1302
1279
  }
1280
+ function shouldDisplaySourceInStatus(source) {
1281
+ return (source.installed ||
1282
+ Boolean(source.lastRunOutcome) ||
1283
+ source.dataState !== "none" ||
1284
+ Boolean(source.connectionHealth));
1285
+ }
1286
+ function emitHumanStatusSource(emit, source, sourceLabels) {
1287
+ const displayName = displaySource(source.source, sourceLabels);
1288
+ const presentation = getHumanStatusPresentation(source);
1289
+ const staleTag = source.isOverdue ? ` ${emit.badge("stale", "warning")}` : "";
1290
+ const collectedAgo = source.lastCollectedAt
1291
+ ? `collected ${formatRelativeTime(source.lastCollectedAt)}`
1292
+ : "";
1293
+ emit.keyValue(` ${displayName}`, `${presentation.label}${staleTag}${collectedAgo ? ` ${collectedAgo}` : ""}`, presentation.tone);
1294
+ const detail = formatHumanStatusDetail(source);
1295
+ if (detail) {
1296
+ emit.detail(` \u21b3 ${detail}`);
1297
+ }
1298
+ }
1299
+ function getHumanStatusPresentation(source) {
1300
+ if (source.dataState === "ingest_failed") {
1301
+ return { label: "sync failed", tone: "warning" };
1302
+ }
1303
+ if (source.dataState === "ingest_unavailable") {
1304
+ return { label: "pending sync", tone: "warning" };
1305
+ }
1306
+ if (source.connectionHealth === "needs_reauth") {
1307
+ return { label: "needs login", tone: "warning" };
1308
+ }
1309
+ if (source.connectionHealth === "error") {
1310
+ return { label: "error", tone: "error" };
1311
+ }
1312
+ const presentation = getSourceStatusPresentation(source);
1313
+ if (presentation.label === "needs input") {
1314
+ return { label: "needs login", tone: presentation.tone };
1315
+ }
1316
+ return presentation;
1317
+ }
1318
+ function formatHumanStatusDetail(source) {
1319
+ if (source.dataState === "ingest_failed") {
1320
+ return formatSyncFailureSummary(source);
1321
+ }
1322
+ if (source.dataState === "ingest_unavailable") {
1323
+ return "Personal Server unavailable. Pending sync will retry during scheduled collection. Run `vana server sync`.";
1324
+ }
1325
+ if (source.connectionHealth === "needs_reauth" ||
1326
+ source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
1327
+ return `Authentication required. Run \`vana connect ${source.source}\`.`;
1328
+ }
1329
+ if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
1330
+ return `Manual auth step required. Run \`vana connect ${source.source}\`.`;
1331
+ }
1332
+ if (source.connectionHealth === "error") {
1333
+ const message = formatHealthMessage(source.connectionHealthReason);
1334
+ return `${message ?? "Collection failed."} Run \`vana connect ${source.source}\`.`;
1335
+ }
1336
+ if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
1337
+ return `${humanizeIssue(source.lastError ?? "Collection failed")}. Run \`vana connect ${source.source}\`.`;
1338
+ }
1339
+ if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
1340
+ return "No connector available. Run `vana sources`.";
1341
+ }
1342
+ return null;
1343
+ }
1344
+ function formatSyncFailureSummary(source) {
1345
+ const failedScopes = source.ingestScopes?.filter((scope) => scope.status === "failed") ?? [];
1346
+ if (failedScopes.length === 0) {
1347
+ return `Personal Server sync failed. Run \`vana connect ${source.source}\`.`;
1348
+ }
1349
+ const groupedFailures = new Map();
1350
+ for (const failedScope of failedScopes) {
1351
+ const summary = humanizeIssue(failedScope.error ?? "Sync failed");
1352
+ groupedFailures.set(summary, (groupedFailures.get(summary) ?? 0) + 1);
1353
+ }
1354
+ const entries = Array.from(groupedFailures.entries());
1355
+ if (entries.length === 1) {
1356
+ const [summary, count] = entries[0];
1357
+ return `${summary}${count > 1 ? ` for ${count} scopes` : ""}. Run \`vana connect ${source.source}\`.`;
1358
+ }
1359
+ const summaryParts = entries.map(([summary, count]) => count > 1 ? `${summary} (${count})` : summary);
1360
+ return `${failedScopes.length} scopes failed to sync: ${summaryParts.join("; ")}. Run \`vana connect ${source.source}\`.`;
1361
+ }
1303
1362
  async function runDoctor(options) {
1304
1363
  const payload = await queryDoctor();
1305
1364
  if (options.json) {
@@ -1408,9 +1467,11 @@ async function runServerStatus(options) {
1408
1467
  ? "(auto-detected)"
1409
1468
  : target.source === "config"
1410
1469
  ? "(saved)"
1411
- : target.source === "env"
1412
- ? "(from VANA_PERSONAL_SERVER_URL)"
1413
- : `(${target.source ?? "unknown"})`;
1470
+ : target.source === "auth"
1471
+ ? "(from vana login)"
1472
+ : target.source === "env"
1473
+ ? "(from VANA_PERSONAL_SERVER_URL)"
1474
+ : `(${target.source ?? "unknown"})`;
1414
1475
  emit.keyValue("URL", `${target.url} ${urlSuffix}`, "muted");
1415
1476
  }
1416
1477
  const stateLabel = target.state === "available" ? "healthy" : "Not connected";
@@ -1992,6 +2053,105 @@ async function runCollect(source, options) {
1992
2053
  }
1993
2054
  return runConnect(source, options);
1994
2055
  }
2056
+ function isPendingSyncDataState(dataState) {
2057
+ return dataState === "collected_local" || dataState === "ingest_unavailable";
2058
+ }
2059
+ function shouldRetryPendingSource(stored, mode) {
2060
+ if (!stored?.lastResultPath) {
2061
+ return false;
2062
+ }
2063
+ if (isPendingSyncDataState(stored.dataState)) {
2064
+ return true;
2065
+ }
2066
+ if (mode === "manual") {
2067
+ return (stored.dataState === "ingest_failed" ||
2068
+ stored.ingestScopes?.some((scope) => scope.status === "failed") === true);
2069
+ }
2070
+ return false;
2071
+ }
2072
+ function buildRetryOptions(stored, mode) {
2073
+ if (!stored) {
2074
+ return undefined;
2075
+ }
2076
+ if (mode !== "manual") {
2077
+ return undefined;
2078
+ }
2079
+ const failedScopes = stored.ingestScopes
2080
+ ?.filter((scope) => scope.status === "failed")
2081
+ .map((scope) => scope.scope) ?? [];
2082
+ return failedScopes.length > 0 ? { scopes: failedScopes } : undefined;
2083
+ }
2084
+ function mergeIngestScopes(previous, current) {
2085
+ if (!previous && !current) {
2086
+ return undefined;
2087
+ }
2088
+ const merged = new Map();
2089
+ for (const scope of previous ?? []) {
2090
+ merged.set(scope.scope, { ...scope });
2091
+ }
2092
+ const now = new Date().toISOString();
2093
+ for (const scope of current ?? []) {
2094
+ merged.set(scope.scope, {
2095
+ scope: scope.scope,
2096
+ status: scope.status === "stored" ? "stored" : "failed",
2097
+ syncedAt: scope.status === "stored" ? now : merged.get(scope.scope)?.syncedAt,
2098
+ error: scope.status === "failed" ? scope.error : undefined,
2099
+ });
2100
+ }
2101
+ return Array.from(merged.values()).sort((left, right) => left.scope.localeCompare(right.scope));
2102
+ }
2103
+ function deriveSyncedDataState(scopes, fallback) {
2104
+ if (!scopes || scopes.length === 0) {
2105
+ return fallback ?? "ingest_unavailable";
2106
+ }
2107
+ const storedCount = scopes.filter((scope) => scope.status === "stored").length;
2108
+ const failedCount = scopes.filter((scope) => scope.status === "failed").length;
2109
+ if (storedCount === 0 && failedCount > 0) {
2110
+ return "ingest_failed";
2111
+ }
2112
+ return "ingested_personal_server";
2113
+ }
2114
+ function summarizeSyncError(scopes) {
2115
+ const failedScopes = scopes?.filter((scope) => scope.status === "failed") ?? [];
2116
+ if (failedScopes.length === 0) {
2117
+ return null;
2118
+ }
2119
+ return failedScopes
2120
+ .map((scope) => `${scope.scope}: ${scope.error ?? "sync failed"}`)
2121
+ .join("; ");
2122
+ }
2123
+ async function syncPendingSources(target, mode) {
2124
+ const state = await readCliState();
2125
+ const pendingSources = Object.entries(state.sources).filter(([, stored]) => shouldRetryPendingSource(stored, mode));
2126
+ if (pendingSources.length === 0) {
2127
+ return { syncedCount: 0, sourceResults: [] };
2128
+ }
2129
+ let syncedCount = 0;
2130
+ const sourceResults = [];
2131
+ for (const [source, stored] of pendingSources) {
2132
+ if (!stored?.lastResultPath) {
2133
+ continue;
2134
+ }
2135
+ const ingestOptions = buildRetryOptions(stored, mode);
2136
+ const ingestEvents = await ingestResult(source, stored.lastResultPath, target, ingestOptions);
2137
+ const resultEvent = ingestEvents.find((event) => event.type === "ingest-complete" ||
2138
+ event.type === "ingest-partial" ||
2139
+ event.type === "ingest-failed");
2140
+ const mergedScopes = mergeIngestScopes(stored.ingestScopes, resultEvent?.scopeResults);
2141
+ const ingestCompleted = ingestEvents.some((event) => event.type === "ingest-complete");
2142
+ const ingestPartial = ingestEvents.some((event) => event.type === "ingest-partial");
2143
+ if (ingestCompleted || ingestPartial) {
2144
+ syncedCount++;
2145
+ await updateSourceState(source, {
2146
+ dataState: deriveSyncedDataState(mergedScopes, stored.dataState),
2147
+ ingestScopes: mergedScopes,
2148
+ lastError: summarizeSyncError(mergedScopes),
2149
+ });
2150
+ }
2151
+ sourceResults.push({ source, scopeResults: resultEvent?.scopeResults });
2152
+ }
2153
+ return { syncedCount, sourceResults };
2154
+ }
1995
2155
  async function runCollectAll(options) {
1996
2156
  const emit = createEmitter(options);
1997
2157
  const state = await readCliState();
@@ -1999,22 +2159,37 @@ async function runCollectAll(options) {
1999
2159
  .filter(([, stored]) => stored?.connectorInstalled &&
2000
2160
  isCollectionDue(stored.exportFrequency, stored.lastCollectedAt))
2001
2161
  .map(([id]) => id);
2162
+ let exitCode = 0;
2163
+ for (const source of dueSources) {
2164
+ const result = await runConnect(source, options);
2165
+ if (result !== 0) {
2166
+ exitCode = result;
2167
+ }
2168
+ }
2169
+ let syncedPendingCount = 0;
2170
+ const target = await detectPersonalServerTarget();
2171
+ if (target.state === "available") {
2172
+ syncedPendingCount = (await syncPendingSources(target, "automatic"))
2173
+ .syncedCount;
2174
+ }
2002
2175
  if (dueSources.length === 0) {
2176
+ if (syncedPendingCount > 0) {
2177
+ if (options.json) {
2178
+ process.stdout.write(`${JSON.stringify({ message: `Synced ${syncedPendingCount} pending dataset(s).`, count: 0, syncedPendingCount })}\n`);
2179
+ }
2180
+ else {
2181
+ emit.info(`Synced ${syncedPendingCount} pending dataset(s).`);
2182
+ }
2183
+ return 0;
2184
+ }
2003
2185
  if (options.json) {
2004
- process.stdout.write(`${JSON.stringify({ message: "No sources are due for collection.", count: 0 })}\n`);
2186
+ process.stdout.write(`${JSON.stringify({ message: "No sources are due for collection.", count: 0, syncedPendingCount: 0 })}\n`);
2005
2187
  }
2006
2188
  else {
2007
2189
  emit.info("No sources are due for collection.");
2008
2190
  }
2009
2191
  return 0;
2010
2192
  }
2011
- let exitCode = 0;
2012
- for (const source of dueSources) {
2013
- const result = await runConnect(source, options);
2014
- if (result !== 0) {
2015
- exitCode = result;
2016
- }
2017
- }
2018
2193
  return exitCode;
2019
2194
  }
2020
2195
  async function runServerSync(options) {
@@ -2032,13 +2207,8 @@ async function runServerSync(options) {
2032
2207
  }
2033
2208
  return 1;
2034
2209
  }
2035
- const state = await readCliState();
2036
- // Find sources that are local-only OR have failed ingest scopes
2037
- const pendingSources = Object.entries(state.sources).filter(([, stored]) => stored?.lastResultPath &&
2038
- (stored.dataState === "collected_local" ||
2039
- stored.dataState === "ingest_failed" ||
2040
- stored?.ingestScopes?.some((s) => s.status === "failed")));
2041
- if (pendingSources.length === 0) {
2210
+ const syncResult = await syncPendingSources(target, "manual");
2211
+ if (syncResult.sourceResults.length === 0) {
2042
2212
  if (options.json) {
2043
2213
  process.stdout.write(`${JSON.stringify({ message: "No pending datasets to sync.", syncedCount: 0 })}\n`);
2044
2214
  }
@@ -2047,46 +2217,13 @@ async function runServerSync(options) {
2047
2217
  }
2048
2218
  return 0;
2049
2219
  }
2050
- let syncedCount = 0;
2051
- const allScopeResults = [];
2052
- for (const [source, stored] of pendingSources) {
2053
- if (!stored?.lastResultPath) {
2054
- continue;
2055
- }
2056
- const ingestEvents = await ingestResult(source, stored.lastResultPath, target);
2057
- const resultEvent = ingestEvents.find((e) => e.type === "ingest-complete" ||
2058
- e.type === "ingest-partial" ||
2059
- e.type === "ingest-failed");
2060
- const scopeResults = resultEvent?.scopeResults;
2061
- const ingestCompleted = ingestEvents.some((e) => e.type === "ingest-complete");
2062
- const ingestPartial = ingestEvents.some((e) => e.type === "ingest-partial");
2063
- if (ingestCompleted || ingestPartial) {
2064
- syncedCount++;
2065
- const dataState = ingestCompleted || ingestPartial
2066
- ? "ingested_personal_server"
2067
- : stored.dataState;
2068
- await updateSourceState(source, {
2069
- dataState,
2070
- ingestScopes: scopeResults?.map((r) => ({
2071
- scope: r.scope,
2072
- status: r.status,
2073
- syncedAt: r.status === "stored" ? new Date().toISOString() : undefined,
2074
- error: r.error,
2075
- })),
2076
- });
2077
- }
2078
- allScopeResults.push({ source, scopeResults });
2079
- for (const event of ingestEvents) {
2080
- emit.event(event);
2081
- }
2082
- }
2083
2220
  if (options.json) {
2084
- process.stdout.write(`${JSON.stringify({ message: `Synced ${syncedCount} dataset(s).`, syncedCount })}\n`);
2221
+ process.stdout.write(`${JSON.stringify({ message: `Synced ${syncResult.syncedCount} dataset(s).`, syncedCount: syncResult.syncedCount })}\n`);
2085
2222
  }
2086
2223
  else {
2087
2224
  // Show per-scope results with scope manifest style
2088
2225
  const renderer = createHumanRenderer();
2089
- for (const entry of allScopeResults) {
2226
+ for (const entry of syncResult.sourceResults) {
2090
2227
  if (entry.scopeResults && entry.scopeResults.length > 0) {
2091
2228
  emit.info(`${entry.source}:`);
2092
2229
  for (const sr of entry.scopeResults) {
@@ -2101,9 +2238,9 @@ async function runServerSync(options) {
2101
2238
  }
2102
2239
  }
2103
2240
  emit.blank();
2104
- const allStored = allScopeResults.every((entry) => !entry.scopeResults ||
2241
+ const allStored = syncResult.sourceResults.every((entry) => !entry.scopeResults ||
2105
2242
  entry.scopeResults.every((sr) => sr.status === "stored"));
2106
- emit.success(`Synced ${syncedCount} dataset(s).`);
2243
+ emit.success(`Synced ${syncResult.syncedCount} dataset(s).`);
2107
2244
  emit.blank();
2108
2245
  if (allStored) {
2109
2246
  emit.next("vana data list");
@@ -2129,12 +2266,20 @@ async function runServerData(scope, options) {
2129
2266
  }
2130
2267
  // If PS is available, try to list remote scopes via client
2131
2268
  let remoteScopes = [];
2269
+ let didQueryRemoteScopes = false;
2132
2270
  let remoteScopeFallbackReason;
2133
2271
  if (target.state === "available" && target.url) {
2272
+ const auth = resolvePersonalServerAuthConfig(target.url);
2134
2273
  try {
2135
- const { createPersonalServerClient: createClient } = await import("../personal-server/client.js");
2136
- const client = createClient({ url: target.url });
2137
- remoteScopes = await client.listScopes(scope);
2274
+ if (auth?.type === "bearerToken") {
2275
+ const { createPersonalServerClient: createClient } = await import("../personal-server/client.js");
2276
+ const client = createClient({
2277
+ url: target.url,
2278
+ auth,
2279
+ });
2280
+ didQueryRemoteScopes = true;
2281
+ remoteScopes = await client.listScopes(scope);
2282
+ }
2138
2283
  }
2139
2284
  catch (err) {
2140
2285
  remoteScopeFallbackReason =
@@ -2142,7 +2287,7 @@ async function runServerData(scope, options) {
2142
2287
  }
2143
2288
  }
2144
2289
  // Use remote scopes if available, otherwise fall back to local
2145
- const scopeList = remoteScopes.length > 0
2290
+ const scopeList = didQueryRemoteScopes
2146
2291
  ? remoteScopes.map((s) => ({
2147
2292
  scope: s.scope,
2148
2293
  detail: `${s.count} version${s.count !== 1 ? "s" : ""}`,
@@ -2155,13 +2300,15 @@ async function runServerData(scope, options) {
2155
2300
  process.stdout.write(`${JSON.stringify({
2156
2301
  count: scopeList.length,
2157
2302
  scopes: scopeList,
2158
- source: remoteScopes.length > 0 ? "remote" : "local",
2303
+ source: didQueryRemoteScopes ? "remote" : "local",
2159
2304
  ...(remoteScopeFallbackReason ? { remoteScopeFallbackReason } : {}),
2160
2305
  })}\n`);
2161
2306
  return 0;
2162
2307
  }
2163
2308
  if (scopeList.length === 0) {
2164
- emit.info("No scopes found.");
2309
+ emit.info(didQueryRemoteScopes
2310
+ ? "No data on your Personal Server."
2311
+ : "No scopes found.");
2165
2312
  if (target.state !== "available") {
2166
2313
  emit.detail("Personal Server is not available. Showing locally-known scopes only.");
2167
2314
  }
@@ -2170,7 +2317,7 @@ async function runServerData(scope, options) {
2170
2317
  for (const entry of scopeList) {
2171
2318
  emit.keyValue(entry.scope, entry.detail, "muted");
2172
2319
  }
2173
- if (remoteScopes.length === 0 && localScopes.length > 0) {
2320
+ if (!didQueryRemoteScopes && localScopes.length > 0) {
2174
2321
  emit.blank();
2175
2322
  emit.detail("Showing locally-known scopes. Connect your Personal Server for live data.");
2176
2323
  }
@@ -2358,6 +2505,62 @@ export function humanizeIssue(message) {
2358
2505
  if (/checksum|mismatch/i.test(message)) {
2359
2506
  return "Connector is out of date. Will auto-update on next connect.";
2360
2507
  }
2508
+ const transportSummary = summarizeTransportError(message);
2509
+ if (transportSummary) {
2510
+ return transportSummary;
2511
+ }
2512
+ return message;
2513
+ }
2514
+ function summarizeTransportError(message) {
2515
+ const httpMatch = message.match(/^HTTP\s+\d+:\s*(.+)$/s);
2516
+ if (!httpMatch) {
2517
+ return null;
2518
+ }
2519
+ const payload = httpMatch[1].trim();
2520
+ const parsed = parseTransportErrorPayload(payload);
2521
+ if (parsed) {
2522
+ return parsed.message;
2523
+ }
2524
+ const messageMatch = payload.match(/"message"\s*:\s*"([^"]+)"/);
2525
+ if (messageMatch) {
2526
+ return messageMatch[1];
2527
+ }
2528
+ return "Request failed";
2529
+ }
2530
+ function parseTransportErrorPayload(payload) {
2531
+ try {
2532
+ const parsed = JSON.parse(payload);
2533
+ const rawError = typeof parsed.error === "string"
2534
+ ? parsed.error
2535
+ : parsed.error && typeof parsed.error === "object"
2536
+ ? (parsed.error.errorCode ??
2537
+ parsed.error.code)
2538
+ : undefined;
2539
+ const rawMessage = typeof parsed.message === "string"
2540
+ ? parsed.message
2541
+ : parsed.error && typeof parsed.error === "object"
2542
+ ? parsed.error.message
2543
+ : undefined;
2544
+ const code = typeof rawError === "string" || typeof rawError === "number"
2545
+ ? String(rawError)
2546
+ : undefined;
2547
+ const message = typeof rawMessage === "string" ? rawMessage : "Request failed";
2548
+ return { code, message: summarizeTransportCode(code, message) };
2549
+ }
2550
+ catch {
2551
+ return null;
2552
+ }
2553
+ }
2554
+ function summarizeTransportCode(code, message) {
2555
+ if (code === "MISSING_AUTH" || /missing authentication/i.test(message)) {
2556
+ return "Authentication required";
2557
+ }
2558
+ if (code === "NO_SCHEMA" || /no schema registered/i.test(message)) {
2559
+ return "No schema registered";
2560
+ }
2561
+ if (code === "INVALID_SCOPE" || /scope must be/i.test(message)) {
2562
+ return "Unsupported scope";
2563
+ }
2361
2564
  return message;
2362
2565
  }
2363
2566
  function formatHumanSourceMessage(message, source, displayName) {
@@ -2391,11 +2594,13 @@ export async function gatherSourceStatuses(storedSources, metadata = {}) {
2391
2594
  const details = metadata[source];
2392
2595
  const dataState = stored.dataState === "ingested_personal_server"
2393
2596
  ? "ingested_personal_server"
2394
- : stored.dataState === "ingest_failed"
2395
- ? "ingest_failed"
2396
- : stored.dataState === "collected_local"
2397
- ? "collected_local"
2398
- : "none";
2597
+ : stored.dataState === "ingest_unavailable"
2598
+ ? "ingest_unavailable"
2599
+ : stored.dataState === "ingest_failed"
2600
+ ? "ingest_failed"
2601
+ : stored.dataState === "collected_local"
2602
+ ? "collected_local"
2603
+ : "none";
2399
2604
  const ingestScopes = stored.ingestScopes;
2400
2605
  const syncedScopeCount = ingestScopes?.filter((s) => s.status === "stored").length ?? 0;
2401
2606
  const failedScopeCount = ingestScopes?.filter((s) => s.status === "failed").length ?? 0;
@@ -2554,6 +2759,14 @@ function formatSourceStatusDetails(source) {
2554
2759
  tone: "success",
2555
2760
  });
2556
2761
  }
2762
+ else if (source.dataState === "ingest_unavailable") {
2763
+ details.push({
2764
+ kind: "row",
2765
+ label: "State",
2766
+ value: "Pending sync to Personal Server",
2767
+ tone: "warning",
2768
+ });
2769
+ }
2557
2770
  else if (source.dataState === "ingest_failed") {
2558
2771
  details.push({
2559
2772
  kind: "row",
@@ -2609,11 +2822,15 @@ function formatSourceStatusDetails(source) {
2609
2822
  }
2610
2823
  export function buildStatusNextSteps(sources, sourceLabels = {}, runtime = "unhealthy", availableSources = []) {
2611
2824
  const nextSteps = [];
2612
- const highestPriority = [...sources].sort(compareSourceStatusOrder)[0];
2825
+ const attentionSources = [...sources]
2826
+ .filter(isSourceAttention)
2827
+ .sort(compareAttentionPriority);
2828
+ const highestPriority = attentionSources[0] ?? [...sources].sort(compareSourceStatusOrder)[0];
2613
2829
  const connectedSources = sources.filter((source) => source.dataState === "collected_local" ||
2614
2830
  source.dataState === "ingested_personal_server" ||
2615
- source.dataState === "ingest_failed");
2616
- const needsAttention = sources.some((source) => rankSourceStatus(source) <= 4);
2831
+ source.dataState === "ingest_failed" ||
2832
+ source.dataState === "ingest_unavailable");
2833
+ const needsAttention = attentionSources.length > 0;
2617
2834
  const highestPriorityLabel = highestPriority
2618
2835
  ? displaySource(highestPriority.source, sourceLabels)
2619
2836
  : null;
@@ -2636,8 +2853,22 @@ export function buildStatusNextSteps(sources, sourceLabels = {}, runtime = "unhe
2636
2853
  nextSteps.push("Inspect install health with `vana doctor`.");
2637
2854
  }
2638
2855
  }
2639
- else if (highestPriority.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
2640
- nextSteps.push(`Continue ${highestPriorityLabel} with \`vana connect ${highestPriority.source}\`.`);
2856
+ else if (highestPriority.dataState === "ingest_failed") {
2857
+ nextSteps.push(`Reconnect ${highestPriorityLabel} with \`vana connect ${highestPriority.source}\`.`);
2858
+ }
2859
+ else if (highestPriority.dataState === "ingest_unavailable") {
2860
+ nextSteps.push("Retry pending Personal Server sync with `vana server sync`.");
2861
+ }
2862
+ else if (highestPriority.connectionHealth === "needs_reauth" ||
2863
+ highestPriority.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
2864
+ nextSteps.push(`Reconnect ${highestPriorityLabel} with \`vana connect ${highestPriority.source}\`.`);
2865
+ if (highestPriority.lastLogPath) {
2866
+ nextSteps.push(`Inspect the latest run log with \`vana logs ${highestPriority.source}\`.`);
2867
+ }
2868
+ }
2869
+ else if (highestPriority.connectionHealth === "error" ||
2870
+ highestPriority.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
2871
+ nextSteps.push(`Retry ${highestPriorityLabel} with \`vana connect ${highestPriority.source}\`.`);
2641
2872
  if (highestPriority.lastLogPath) {
2642
2873
  nextSteps.push(`Inspect the latest run log with \`vana logs ${highestPriority.source}\`.`);
2643
2874
  }
@@ -2655,8 +2886,7 @@ export function buildStatusNextSteps(sources, sourceLabels = {}, runtime = "unhe
2655
2886
  }
2656
2887
  }
2657
2888
  else if (highestPriority.dataState === "collected_local" ||
2658
- highestPriority.dataState === "ingested_personal_server" ||
2659
- highestPriority.dataState === "ingest_failed") {
2889
+ highestPriority.dataState === "ingested_personal_server") {
2660
2890
  if (connectedSources.length > 1) {
2661
2891
  nextSteps.push("Review your collected data with `vana data list`.");
2662
2892
  }
@@ -2961,6 +3191,9 @@ export function getSourceStatusPresentation(source) {
2961
3191
  }
2962
3192
  return { label: "synced", tone: "success" };
2963
3193
  }
3194
+ if (source.dataState === "ingest_unavailable") {
3195
+ return { label: "pending sync", tone: "warning" };
3196
+ }
2964
3197
  if (source.dataState === "collected_local") {
2965
3198
  return { label: "local", tone: "muted" };
2966
3199
  }
@@ -3050,6 +3283,48 @@ export function compareSourceStatusOrder(left, right) {
3050
3283
  authMode: right.authMode,
3051
3284
  }));
3052
3285
  }
3286
+ export function isSourceAttention(source) {
3287
+ if (source.dataState === "ingest_failed" ||
3288
+ source.dataState === "ingest_unavailable") {
3289
+ return true;
3290
+ }
3291
+ if (source.connectionHealth === "needs_reauth" ||
3292
+ source.connectionHealth === "error" ||
3293
+ source.connectionHealth === "stale") {
3294
+ return true;
3295
+ }
3296
+ return rankSourceStatus(source) <= 4;
3297
+ }
3298
+ function compareAttentionPriority(left, right) {
3299
+ const rank = (source) => {
3300
+ if (source.dataState === "ingest_failed") {
3301
+ return 0;
3302
+ }
3303
+ if (source.dataState === "ingest_unavailable") {
3304
+ return 1;
3305
+ }
3306
+ if (source.connectionHealth === "needs_reauth") {
3307
+ return 2;
3308
+ }
3309
+ if (source.connectionHealth === "error") {
3310
+ return 3;
3311
+ }
3312
+ if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
3313
+ return 4;
3314
+ }
3315
+ if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
3316
+ return 5;
3317
+ }
3318
+ if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
3319
+ return 6;
3320
+ }
3321
+ if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
3322
+ return 7;
3323
+ }
3324
+ return 8;
3325
+ };
3326
+ return rank(left) - rank(right) || compareSourceStatusOrder(left, right);
3327
+ }
3053
3328
  export function rankSourceStatus(source) {
3054
3329
  if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
3055
3330
  return 0;
@@ -3189,6 +3464,7 @@ function compareLogRecordOrder(left, right) {
3189
3464
  }
3190
3465
  export function hasCollectedData(dataState) {
3191
3466
  return (dataState === "collected_local" ||
3467
+ dataState === "ingest_unavailable" ||
3192
3468
  dataState === "ingested_personal_server" ||
3193
3469
  dataState === "ingest_failed");
3194
3470
  }
@@ -3208,6 +3484,9 @@ function formatLogOutcomeLabel(lastRunOutcome, dataState) {
3208
3484
  if (dataState === "ingested_personal_server") {
3209
3485
  return "synced";
3210
3486
  }
3487
+ if (dataState === "ingest_unavailable") {
3488
+ return "pending sync";
3489
+ }
3211
3490
  if (dataState === "ingest_failed") {
3212
3491
  return "sync failed";
3213
3492
  }
@@ -3229,7 +3508,8 @@ function toneForLogOutcome(lastRunOutcome, dataState) {
3229
3508
  if (lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE ||
3230
3509
  lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH ||
3231
3510
  lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT ||
3232
- dataState === "ingest_failed") {
3511
+ dataState === "ingest_failed" ||
3512
+ dataState === "ingest_unavailable") {
3233
3513
  return "warning";
3234
3514
  }
3235
3515
  if (dataState === "ingested_personal_server") {
@@ -3240,20 +3520,6 @@ function toneForLogOutcome(lastRunOutcome, dataState) {
3240
3520
  }
3241
3521
  return "muted";
3242
3522
  }
3243
- function toneForHealth(health) {
3244
- switch (health) {
3245
- case "healthy":
3246
- return "accent";
3247
- case "needs_reauth":
3248
- return "warning";
3249
- case "error":
3250
- return "error";
3251
- case "stale":
3252
- return "muted";
3253
- default:
3254
- return "muted";
3255
- }
3256
- }
3257
3523
  // ---------------------------------------------------------------------------
3258
3524
  // Detach (background process)
3259
3525
  // ---------------------------------------------------------------------------
@@ -3959,4 +4225,195 @@ async function runSkillShow(name, options) {
3959
4225
  return 1;
3960
4226
  }
3961
4227
  }
4228
+ // ── Login / Logout ─────────────────────────────────────────────────────
4229
+ async function runLogin(options, serverUrl) {
4230
+ // Determine auth target: cloud (account.vana.org) or self-hosted (PS directly)
4231
+ const psUrl = serverUrl ?? resolvePersonalServerUrl() ?? null;
4232
+ const authTarget = getAuthTarget(psUrl);
4233
+ // If self-hosted, use /auth/device flow against the PS
4234
+ if (authTarget === "self-hosted" && psUrl) {
4235
+ const renderer = !options.json && !options.quiet ? createLoginRenderer() : null;
4236
+ renderer?.title(psUrl);
4237
+ try {
4238
+ const result = await runSelfHostedLoginFlow(psUrl, (url) => {
4239
+ renderer?.note("Open this URL in your browser:");
4240
+ renderer?.note(url);
4241
+ renderer?.scopeActive("Waiting for authorization");
4242
+ // Try to open browser — use spawn with args array to prevent shell injection
4243
+ // (a malicious self-hosted PS could return a URL with shell metacharacters)
4244
+ try {
4245
+ const { spawn } = require("node:child_process");
4246
+ const opener = process.platform === "darwin"
4247
+ ? "open"
4248
+ : process.platform === "win32"
4249
+ ? "start"
4250
+ : "xdg-open";
4251
+ spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
4252
+ }
4253
+ catch {
4254
+ // Browser open failed — user will open manually
4255
+ }
4256
+ });
4257
+ await saveCredentials({
4258
+ account: {
4259
+ address: result.address,
4260
+ session_token: "",
4261
+ expires_at: result.expires_at,
4262
+ },
4263
+ personal_server: {
4264
+ url: psUrl,
4265
+ session_token: result.session_token,
4266
+ expires_at: result.expires_at,
4267
+ },
4268
+ });
4269
+ await updateCliConfig({ personalServerUrl: psUrl });
4270
+ renderer?.success(`Logged in to ${psUrl}`);
4271
+ renderer?.detail("Credentials saved to ~/.vana/auth.json");
4272
+ return 0;
4273
+ }
4274
+ catch (err) {
4275
+ renderer?.fail("Login failed");
4276
+ renderer?.detail(err instanceof Error ? err.message : String(err));
4277
+ renderer?.next(`vana login --server ${psUrl}`);
4278
+ return 1;
4279
+ }
4280
+ finally {
4281
+ renderer?.cleanup();
4282
+ }
4283
+ }
4284
+ // Cloud flow (account.vana.org)
4285
+ // Check env var shortcut
4286
+ const envToken = process.env.VANA_SESSION_TOKEN;
4287
+ if (envToken) {
4288
+ const creds = loadCredentials();
4289
+ if (creds) {
4290
+ if (options.json) {
4291
+ process.stdout.write(`${JSON.stringify({ status: "authenticated", source: "env", address: creds.account.address })}\n`);
4292
+ }
4293
+ else {
4294
+ const emit = createEmitter(options);
4295
+ emit.success(`Already authenticated via VANA_SESSION_TOKEN env var`);
4296
+ }
4297
+ return 0;
4298
+ }
4299
+ }
4300
+ // Check if already logged in
4301
+ const existing = loadCredentials();
4302
+ if (existing && !isExpired(existing)) {
4303
+ if (options.json) {
4304
+ process.stdout.write(`${JSON.stringify({
4305
+ status: "authenticated",
4306
+ address: existing.account.address,
4307
+ personal_server: existing.personal_server?.url ?? null,
4308
+ expires_at: existing.account.expires_at,
4309
+ })}\n`);
4310
+ }
4311
+ else {
4312
+ const emit = createEmitter(options);
4313
+ emit.success(`Already logged in as ${formatAddress(existing.account.address)}`);
4314
+ if (existing.personal_server) {
4315
+ emit.keyValue("Personal Server", existing.personal_server.url, "success");
4316
+ }
4317
+ emit.info(` Auth expires in ${formatExpiresIn(existing.account.expires_at)}`);
4318
+ emit.blank();
4319
+ emit.info(" Run `vana logout` first to re-authenticate.");
4320
+ }
4321
+ return 0;
4322
+ }
4323
+ if (options.json) {
4324
+ // JSON mode: run flow and output result
4325
+ const creds = await runDeviceCodeFlow({
4326
+ onCode: (code, uri) => {
4327
+ process.stderr.write(JSON.stringify({ event: "device_code", code, uri }) + "\n");
4328
+ },
4329
+ onWaiting: () => { },
4330
+ onAuthorized: () => { },
4331
+ onExpired: () => {
4332
+ process.stdout.write(JSON.stringify({ status: "expired", error: "Device code expired" }) +
4333
+ "\n");
4334
+ },
4335
+ onError: (err) => {
4336
+ process.stdout.write(JSON.stringify({ status: "error", error: err.message }) + "\n");
4337
+ },
4338
+ });
4339
+ if (creds) {
4340
+ await saveCredentials(creds);
4341
+ if (creds.personal_server?.url) {
4342
+ await updateCliConfig({ personalServerUrl: creds.personal_server.url });
4343
+ }
4344
+ process.stdout.write(`${JSON.stringify({
4345
+ status: "authenticated",
4346
+ address: creds.account.address,
4347
+ personal_server: creds.personal_server?.url ?? null,
4348
+ expires_at: creds.account.expires_at,
4349
+ })}\n`);
4350
+ return 0;
4351
+ }
4352
+ return 1;
4353
+ }
4354
+ // Interactive mode
4355
+ const renderer = createLoginRenderer();
4356
+ renderer.title("Vana");
4357
+ const creds = await runDeviceCodeFlow({
4358
+ onCode: (code, uri) => {
4359
+ renderer.note("Open this URL in your browser:");
4360
+ renderer.note(uri);
4361
+ renderer.note(`Enter this code: ${code}`);
4362
+ },
4363
+ onWaiting: () => {
4364
+ renderer.scopeActive("Waiting for authorization");
4365
+ },
4366
+ onAuthorized: async (authedCreds) => {
4367
+ await saveCredentials(authedCreds);
4368
+ if (authedCreds.personal_server?.url) {
4369
+ await updateCliConfig({
4370
+ personalServerUrl: authedCreds.personal_server.url,
4371
+ });
4372
+ }
4373
+ renderer.success(`Logged in as ${formatAddress(authedCreds.account.address)}`);
4374
+ if (authedCreds.personal_server) {
4375
+ renderer.detail(`Personal Server: ${authedCreds.personal_server.url}`);
4376
+ }
4377
+ renderer.detail("Credentials saved to ~/.vana/auth.json");
4378
+ },
4379
+ onExpired: () => {
4380
+ renderer.fail("Authorization expired");
4381
+ renderer.next("vana login");
4382
+ },
4383
+ onError: (err) => {
4384
+ renderer.fail("Login failed");
4385
+ renderer.detail(err.message);
4386
+ renderer.next("vana login");
4387
+ },
4388
+ });
4389
+ renderer.cleanup();
4390
+ return creds ? 0 : 1;
4391
+ }
4392
+ async function runLogout(options) {
4393
+ // Revoke the token server-side before clearing local credentials
4394
+ const creds = loadCredentials();
4395
+ if (creds?.personal_server?.url && creds.personal_server.session_token) {
4396
+ try {
4397
+ await fetch(`${creds.personal_server.url.replace(/\/$/, "")}/auth/device/token`, {
4398
+ method: "DELETE",
4399
+ headers: {
4400
+ Authorization: `Bearer ${creds.personal_server.session_token}`,
4401
+ },
4402
+ signal: AbortSignal.timeout(5000),
4403
+ });
4404
+ }
4405
+ catch {
4406
+ // Best-effort — server may be down, but we still clear local creds
4407
+ }
4408
+ }
4409
+ await clearCredentials();
4410
+ if (options.json) {
4411
+ process.stdout.write(`${JSON.stringify({ status: "logged_out" })}\n`);
4412
+ }
4413
+ else {
4414
+ const emit = createEmitter(options);
4415
+ emit.success("Logged out. Credentials removed.");
4416
+ }
4417
+ return 0;
4418
+ }
3962
4419
  //# sourceMappingURL=index.js.map