@opendatalabs/connect 0.10.0 → 0.10.1-canary.5944de8

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 (40) hide show
  1. package/dist/cli/auth.d.ts +112 -0
  2. package/dist/cli/auth.d.ts.map +1 -0
  3. package/dist/cli/auth.js +339 -0
  4. package/dist/cli/auth.js.map +1 -0
  5. package/dist/cli/index.d.ts +1 -0
  6. package/dist/cli/index.d.ts.map +1 -1
  7. package/dist/cli/index.js +657 -140
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/queries.d.ts.map +1 -1
  10. package/dist/cli/queries.js +16 -9
  11. package/dist/cli/queries.js.map +1 -1
  12. package/dist/cli/render/connect-renderer.d.ts +5 -0
  13. package/dist/cli/render/connect-renderer.d.ts.map +1 -1
  14. package/dist/cli/render/connect-renderer.js +62 -58
  15. package/dist/cli/render/connect-renderer.js.map +1 -1
  16. package/dist/cli/render/flow-theme.d.ts +17 -0
  17. package/dist/cli/render/flow-theme.d.ts.map +1 -0
  18. package/dist/cli/render/flow-theme.js +26 -0
  19. package/dist/cli/render/flow-theme.js.map +1 -0
  20. package/dist/cli/render/index.d.ts +1 -0
  21. package/dist/cli/render/index.d.ts.map +1 -1
  22. package/dist/cli/render/index.js +1 -0
  23. package/dist/cli/render/index.js.map +1 -1
  24. package/dist/cli/render/theme.d.ts +1 -0
  25. package/dist/cli/render/theme.d.ts.map +1 -1
  26. package/dist/cli/render/theme.js +19 -3
  27. package/dist/cli/render/theme.js.map +1 -1
  28. package/dist/core/cli-types.d.ts +3 -0
  29. package/dist/core/cli-types.d.ts.map +1 -1
  30. package/dist/core/cli-types.js +1 -1
  31. package/dist/core/cli-types.js.map +1 -1
  32. package/dist/personal-server/client.d.ts +1 -1
  33. package/dist/personal-server/client.d.ts.map +1 -1
  34. package/dist/personal-server/client.js +31 -17
  35. package/dist/personal-server/client.js.map +1 -1
  36. package/dist/personal-server/index.d.ts +12 -2
  37. package/dist/personal-server/index.d.ts.map +1 -1
  38. package/dist/personal-server/index.js +52 -18
  39. package/dist/personal-server/index.js.map +1 -1
  40. 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,60 +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 healthTone = sourceOverdue ? "warning" : toneForHealth(health);
1208
- const healthLabel = health === "needs_reauth" ? "needs login" : health;
1209
- const staleTag = sourceOverdue
1210
- ? ` ${emit.badge("stale", "warning")}`
1211
- : "";
1212
- const collectedAgo = stored?.lastCollectedAt
1213
- ? `collected ${formatRelativeTime(stored.lastCollectedAt)}`
1214
- : "";
1215
- emit.keyValue(` ${displayName}`, `${healthLabel}${staleTag} ${collectedAgo}`, healthTone);
1216
- if ((health === "needs_reauth" || health === "error") &&
1217
- stored?.connectionHealthReason) {
1218
- const msg = formatHealthMessage(stored.connectionHealthReason);
1219
- const ago = stored.connectionHealthChangedAt
1220
- ? ` (${formatRelativeTime(stored.connectionHealthChangedAt)})`
1221
- : "";
1222
- if (msg) {
1223
- emit.detail(` \u21b3 ${msg}${ago} Run \`vana connect ${sourceId}\``);
1224
- }
1225
- }
1226
- if (health === "needs_reauth" && !needsReauthSource) {
1227
- needsReauthSource = sourceId;
1228
- }
1256
+ emit.section(formatCountLabel("Needs attention", attentionSources.length));
1257
+ for (const source of attentionSources) {
1258
+ emitHumanStatusSource(emit, source, sourceLabels);
1229
1259
  }
1230
- if (needsReauthSource) {
1231
- emit.blank();
1232
- emit.next(`vana connect ${needsReauthSource}`);
1233
- return 0;
1260
+ }
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);
1234
1266
  }
1235
1267
  }
1236
1268
  if (nextSteps.length > 0) {
@@ -1245,6 +1277,88 @@ async function runStatus(options) {
1245
1277
  }
1246
1278
  return 0;
1247
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
+ }
1248
1362
  async function runDoctor(options) {
1249
1363
  const payload = await queryDoctor();
1250
1364
  if (options.json) {
@@ -1353,9 +1467,11 @@ async function runServerStatus(options) {
1353
1467
  ? "(auto-detected)"
1354
1468
  : target.source === "config"
1355
1469
  ? "(saved)"
1356
- : target.source === "env"
1357
- ? "(from VANA_PERSONAL_SERVER_URL)"
1358
- : `(${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"})`;
1359
1475
  emit.keyValue("URL", `${target.url} ${urlSuffix}`, "muted");
1360
1476
  }
1361
1477
  const stateLabel = target.state === "available" ? "healthy" : "Not connected";
@@ -1937,6 +2053,105 @@ async function runCollect(source, options) {
1937
2053
  }
1938
2054
  return runConnect(source, options);
1939
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
+ }
1940
2155
  async function runCollectAll(options) {
1941
2156
  const emit = createEmitter(options);
1942
2157
  const state = await readCliState();
@@ -1944,22 +2159,37 @@ async function runCollectAll(options) {
1944
2159
  .filter(([, stored]) => stored?.connectorInstalled &&
1945
2160
  isCollectionDue(stored.exportFrequency, stored.lastCollectedAt))
1946
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
+ }
1947
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
+ }
1948
2185
  if (options.json) {
1949
- 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`);
1950
2187
  }
1951
2188
  else {
1952
2189
  emit.info("No sources are due for collection.");
1953
2190
  }
1954
2191
  return 0;
1955
2192
  }
1956
- let exitCode = 0;
1957
- for (const source of dueSources) {
1958
- const result = await runConnect(source, options);
1959
- if (result !== 0) {
1960
- exitCode = result;
1961
- }
1962
- }
1963
2193
  return exitCode;
1964
2194
  }
1965
2195
  async function runServerSync(options) {
@@ -1977,13 +2207,8 @@ async function runServerSync(options) {
1977
2207
  }
1978
2208
  return 1;
1979
2209
  }
1980
- const state = await readCliState();
1981
- // Find sources that are local-only OR have failed ingest scopes
1982
- const pendingSources = Object.entries(state.sources).filter(([, stored]) => stored?.lastResultPath &&
1983
- (stored.dataState === "collected_local" ||
1984
- stored.dataState === "ingest_failed" ||
1985
- stored?.ingestScopes?.some((s) => s.status === "failed")));
1986
- if (pendingSources.length === 0) {
2210
+ const syncResult = await syncPendingSources(target, "manual");
2211
+ if (syncResult.sourceResults.length === 0) {
1987
2212
  if (options.json) {
1988
2213
  process.stdout.write(`${JSON.stringify({ message: "No pending datasets to sync.", syncedCount: 0 })}\n`);
1989
2214
  }
@@ -1992,46 +2217,13 @@ async function runServerSync(options) {
1992
2217
  }
1993
2218
  return 0;
1994
2219
  }
1995
- let syncedCount = 0;
1996
- const allScopeResults = [];
1997
- for (const [source, stored] of pendingSources) {
1998
- if (!stored?.lastResultPath) {
1999
- continue;
2000
- }
2001
- const ingestEvents = await ingestResult(source, stored.lastResultPath, target);
2002
- const resultEvent = ingestEvents.find((e) => e.type === "ingest-complete" ||
2003
- e.type === "ingest-partial" ||
2004
- e.type === "ingest-failed");
2005
- const scopeResults = resultEvent?.scopeResults;
2006
- const ingestCompleted = ingestEvents.some((e) => e.type === "ingest-complete");
2007
- const ingestPartial = ingestEvents.some((e) => e.type === "ingest-partial");
2008
- if (ingestCompleted || ingestPartial) {
2009
- syncedCount++;
2010
- const dataState = ingestCompleted || ingestPartial
2011
- ? "ingested_personal_server"
2012
- : stored.dataState;
2013
- await updateSourceState(source, {
2014
- dataState,
2015
- ingestScopes: scopeResults?.map((r) => ({
2016
- scope: r.scope,
2017
- status: r.status,
2018
- syncedAt: r.status === "stored" ? new Date().toISOString() : undefined,
2019
- error: r.error,
2020
- })),
2021
- });
2022
- }
2023
- allScopeResults.push({ source, scopeResults });
2024
- for (const event of ingestEvents) {
2025
- emit.event(event);
2026
- }
2027
- }
2028
2220
  if (options.json) {
2029
- 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`);
2030
2222
  }
2031
2223
  else {
2032
2224
  // Show per-scope results with scope manifest style
2033
2225
  const renderer = createHumanRenderer();
2034
- for (const entry of allScopeResults) {
2226
+ for (const entry of syncResult.sourceResults) {
2035
2227
  if (entry.scopeResults && entry.scopeResults.length > 0) {
2036
2228
  emit.info(`${entry.source}:`);
2037
2229
  for (const sr of entry.scopeResults) {
@@ -2046,9 +2238,9 @@ async function runServerSync(options) {
2046
2238
  }
2047
2239
  }
2048
2240
  emit.blank();
2049
- const allStored = allScopeResults.every((entry) => !entry.scopeResults ||
2241
+ const allStored = syncResult.sourceResults.every((entry) => !entry.scopeResults ||
2050
2242
  entry.scopeResults.every((sr) => sr.status === "stored"));
2051
- emit.success(`Synced ${syncedCount} dataset(s).`);
2243
+ emit.success(`Synced ${syncResult.syncedCount} dataset(s).`);
2052
2244
  emit.blank();
2053
2245
  if (allStored) {
2054
2246
  emit.next("vana data list");
@@ -2074,12 +2266,20 @@ async function runServerData(scope, options) {
2074
2266
  }
2075
2267
  // If PS is available, try to list remote scopes via client
2076
2268
  let remoteScopes = [];
2269
+ let didQueryRemoteScopes = false;
2077
2270
  let remoteScopeFallbackReason;
2078
2271
  if (target.state === "available" && target.url) {
2272
+ const auth = resolvePersonalServerAuthConfig(target.url);
2079
2273
  try {
2080
- const { createPersonalServerClient: createClient } = await import("../personal-server/client.js");
2081
- const client = createClient({ url: target.url });
2082
- 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
+ }
2083
2283
  }
2084
2284
  catch (err) {
2085
2285
  remoteScopeFallbackReason =
@@ -2087,7 +2287,7 @@ async function runServerData(scope, options) {
2087
2287
  }
2088
2288
  }
2089
2289
  // Use remote scopes if available, otherwise fall back to local
2090
- const scopeList = remoteScopes.length > 0
2290
+ const scopeList = didQueryRemoteScopes
2091
2291
  ? remoteScopes.map((s) => ({
2092
2292
  scope: s.scope,
2093
2293
  detail: `${s.count} version${s.count !== 1 ? "s" : ""}`,
@@ -2100,13 +2300,15 @@ async function runServerData(scope, options) {
2100
2300
  process.stdout.write(`${JSON.stringify({
2101
2301
  count: scopeList.length,
2102
2302
  scopes: scopeList,
2103
- source: remoteScopes.length > 0 ? "remote" : "local",
2303
+ source: didQueryRemoteScopes ? "remote" : "local",
2104
2304
  ...(remoteScopeFallbackReason ? { remoteScopeFallbackReason } : {}),
2105
2305
  })}\n`);
2106
2306
  return 0;
2107
2307
  }
2108
2308
  if (scopeList.length === 0) {
2109
- emit.info("No scopes found.");
2309
+ emit.info(didQueryRemoteScopes
2310
+ ? "No data on your Personal Server."
2311
+ : "No scopes found.");
2110
2312
  if (target.state !== "available") {
2111
2313
  emit.detail("Personal Server is not available. Showing locally-known scopes only.");
2112
2314
  }
@@ -2115,7 +2317,7 @@ async function runServerData(scope, options) {
2115
2317
  for (const entry of scopeList) {
2116
2318
  emit.keyValue(entry.scope, entry.detail, "muted");
2117
2319
  }
2118
- if (remoteScopes.length === 0 && localScopes.length > 0) {
2320
+ if (!didQueryRemoteScopes && localScopes.length > 0) {
2119
2321
  emit.blank();
2120
2322
  emit.detail("Showing locally-known scopes. Connect your Personal Server for live data.");
2121
2323
  }
@@ -2303,6 +2505,62 @@ export function humanizeIssue(message) {
2303
2505
  if (/checksum|mismatch/i.test(message)) {
2304
2506
  return "Connector is out of date. Will auto-update on next connect.";
2305
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
+ }
2306
2564
  return message;
2307
2565
  }
2308
2566
  function formatHumanSourceMessage(message, source, displayName) {
@@ -2336,11 +2594,13 @@ export async function gatherSourceStatuses(storedSources, metadata = {}) {
2336
2594
  const details = metadata[source];
2337
2595
  const dataState = stored.dataState === "ingested_personal_server"
2338
2596
  ? "ingested_personal_server"
2339
- : stored.dataState === "ingest_failed"
2340
- ? "ingest_failed"
2341
- : stored.dataState === "collected_local"
2342
- ? "collected_local"
2343
- : "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";
2344
2604
  const ingestScopes = stored.ingestScopes;
2345
2605
  const syncedScopeCount = ingestScopes?.filter((s) => s.status === "stored").length ?? 0;
2346
2606
  const failedScopeCount = ingestScopes?.filter((s) => s.status === "failed").length ?? 0;
@@ -2499,6 +2759,14 @@ function formatSourceStatusDetails(source) {
2499
2759
  tone: "success",
2500
2760
  });
2501
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
+ }
2502
2770
  else if (source.dataState === "ingest_failed") {
2503
2771
  details.push({
2504
2772
  kind: "row",
@@ -2554,11 +2822,15 @@ function formatSourceStatusDetails(source) {
2554
2822
  }
2555
2823
  export function buildStatusNextSteps(sources, sourceLabels = {}, runtime = "unhealthy", availableSources = []) {
2556
2824
  const nextSteps = [];
2557
- 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];
2558
2829
  const connectedSources = sources.filter((source) => source.dataState === "collected_local" ||
2559
2830
  source.dataState === "ingested_personal_server" ||
2560
- source.dataState === "ingest_failed");
2561
- const needsAttention = sources.some((source) => rankSourceStatus(source) <= 4);
2831
+ source.dataState === "ingest_failed" ||
2832
+ source.dataState === "ingest_unavailable");
2833
+ const needsAttention = attentionSources.length > 0;
2562
2834
  const highestPriorityLabel = highestPriority
2563
2835
  ? displaySource(highestPriority.source, sourceLabels)
2564
2836
  : null;
@@ -2581,8 +2853,22 @@ export function buildStatusNextSteps(sources, sourceLabels = {}, runtime = "unhe
2581
2853
  nextSteps.push("Inspect install health with `vana doctor`.");
2582
2854
  }
2583
2855
  }
2584
- else if (highestPriority.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
2585
- 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}\`.`);
2586
2872
  if (highestPriority.lastLogPath) {
2587
2873
  nextSteps.push(`Inspect the latest run log with \`vana logs ${highestPriority.source}\`.`);
2588
2874
  }
@@ -2600,8 +2886,7 @@ export function buildStatusNextSteps(sources, sourceLabels = {}, runtime = "unhe
2600
2886
  }
2601
2887
  }
2602
2888
  else if (highestPriority.dataState === "collected_local" ||
2603
- highestPriority.dataState === "ingested_personal_server" ||
2604
- highestPriority.dataState === "ingest_failed") {
2889
+ highestPriority.dataState === "ingested_personal_server") {
2605
2890
  if (connectedSources.length > 1) {
2606
2891
  nextSteps.push("Review your collected data with `vana data list`.");
2607
2892
  }
@@ -2872,6 +3157,11 @@ export function getSourceStatusPresentation(source) {
2872
3157
  if (!source.installed && !source.lastRunOutcome) {
2873
3158
  return { label: "not connected", tone: "muted" };
2874
3159
  }
3160
+ // Check dataState before early-returning on missing lastRunOutcome —
3161
+ // ingest can fail even when lastRunOutcome is unset
3162
+ if (source.dataState === "ingest_failed") {
3163
+ return { label: "sync failed", tone: "error" };
3164
+ }
2875
3165
  if (!source.lastRunOutcome) {
2876
3166
  return { label: "installed", tone: "success" };
2877
3167
  }
@@ -2901,12 +3191,12 @@ export function getSourceStatusPresentation(source) {
2901
3191
  }
2902
3192
  return { label: "synced", tone: "success" };
2903
3193
  }
3194
+ if (source.dataState === "ingest_unavailable") {
3195
+ return { label: "pending sync", tone: "warning" };
3196
+ }
2904
3197
  if (source.dataState === "collected_local") {
2905
3198
  return { label: "local", tone: "muted" };
2906
3199
  }
2907
- if (source.dataState === "ingest_failed") {
2908
- return { label: "sync failed", tone: "error" };
2909
- }
2910
3200
  return { label: "connected", tone: "success" };
2911
3201
  }
2912
3202
  export function toneForRuntime(runtime) {
@@ -2993,6 +3283,48 @@ export function compareSourceStatusOrder(left, right) {
2993
3283
  authMode: right.authMode,
2994
3284
  }));
2995
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
+ }
2996
3328
  export function rankSourceStatus(source) {
2997
3329
  if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
2998
3330
  return 0;
@@ -3009,6 +3341,9 @@ export function rankSourceStatus(source) {
3009
3341
  if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
3010
3342
  return 4;
3011
3343
  }
3344
+ if (source.dataState === "ingest_failed") {
3345
+ return 2;
3346
+ }
3012
3347
  if (source.dataState === "ingested_personal_server") {
3013
3348
  return 5;
3014
3349
  }
@@ -3129,6 +3464,7 @@ function compareLogRecordOrder(left, right) {
3129
3464
  }
3130
3465
  export function hasCollectedData(dataState) {
3131
3466
  return (dataState === "collected_local" ||
3467
+ dataState === "ingest_unavailable" ||
3132
3468
  dataState === "ingested_personal_server" ||
3133
3469
  dataState === "ingest_failed");
3134
3470
  }
@@ -3148,6 +3484,9 @@ function formatLogOutcomeLabel(lastRunOutcome, dataState) {
3148
3484
  if (dataState === "ingested_personal_server") {
3149
3485
  return "synced";
3150
3486
  }
3487
+ if (dataState === "ingest_unavailable") {
3488
+ return "pending sync";
3489
+ }
3151
3490
  if (dataState === "ingest_failed") {
3152
3491
  return "sync failed";
3153
3492
  }
@@ -3169,7 +3508,8 @@ function toneForLogOutcome(lastRunOutcome, dataState) {
3169
3508
  if (lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE ||
3170
3509
  lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH ||
3171
3510
  lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT ||
3172
- dataState === "ingest_failed") {
3511
+ dataState === "ingest_failed" ||
3512
+ dataState === "ingest_unavailable") {
3173
3513
  return "warning";
3174
3514
  }
3175
3515
  if (dataState === "ingested_personal_server") {
@@ -3180,20 +3520,6 @@ function toneForLogOutcome(lastRunOutcome, dataState) {
3180
3520
  }
3181
3521
  return "muted";
3182
3522
  }
3183
- function toneForHealth(health) {
3184
- switch (health) {
3185
- case "healthy":
3186
- return "accent";
3187
- case "needs_reauth":
3188
- return "warning";
3189
- case "error":
3190
- return "error";
3191
- case "stale":
3192
- return "muted";
3193
- default:
3194
- return "muted";
3195
- }
3196
- }
3197
3523
  // ---------------------------------------------------------------------------
3198
3524
  // Detach (background process)
3199
3525
  // ---------------------------------------------------------------------------
@@ -3899,4 +4225,195 @@ async function runSkillShow(name, options) {
3899
4225
  return 1;
3900
4226
  }
3901
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
+ }
3902
4419
  //# sourceMappingURL=index.js.map