vana-cli 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +320 -0
- package/dist/cli/auth.d.ts +119 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +528 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +15 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/index.d.ts +92 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4703 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +10 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/mcp-server.d.ts +15 -0
- package/dist/cli/mcp-server.d.ts.map +1 -0
- package/dist/cli/mcp-server.js +199 -0
- package/dist/cli/mcp-server.js.map +1 -0
- package/dist/cli/queries.d.ts +128 -0
- package/dist/cli/queries.d.ts.map +1 -0
- package/dist/cli/queries.js +422 -0
- package/dist/cli/queries.js.map +1 -0
- package/dist/cli/render/capabilities.d.ts +9 -0
- package/dist/cli/render/capabilities.d.ts.map +1 -0
- package/dist/cli/render/capabilities.js +24 -0
- package/dist/cli/render/capabilities.js.map +1 -0
- package/dist/cli/render/connect-renderer.d.ts +23 -0
- package/dist/cli/render/connect-renderer.d.ts.map +1 -0
- package/dist/cli/render/connect-renderer.js +259 -0
- package/dist/cli/render/connect-renderer.js.map +1 -0
- package/dist/cli/render/flow-theme.d.ts +17 -0
- package/dist/cli/render/flow-theme.d.ts.map +1 -0
- package/dist/cli/render/flow-theme.js +26 -0
- package/dist/cli/render/flow-theme.js.map +1 -0
- package/dist/cli/render/format.d.ts +27 -0
- package/dist/cli/render/format.d.ts.map +1 -0
- package/dist/cli/render/format.js +111 -0
- package/dist/cli/render/format.js.map +1 -0
- package/dist/cli/render/index.d.ts +8 -0
- package/dist/cli/render/index.d.ts.map +1 -0
- package/dist/cli/render/index.js +8 -0
- package/dist/cli/render/index.js.map +1 -0
- package/dist/cli/render/progress.d.ts +11 -0
- package/dist/cli/render/progress.d.ts.map +1 -0
- package/dist/cli/render/progress.js +56 -0
- package/dist/cli/render/progress.js.map +1 -0
- package/dist/cli/render/symbols.d.ts +11 -0
- package/dist/cli/render/symbols.d.ts.map +1 -0
- package/dist/cli/render/symbols.js +21 -0
- package/dist/cli/render/symbols.js.map +1 -0
- package/dist/cli/render/theme.d.ts +16 -0
- package/dist/cli/render/theme.d.ts.map +1 -0
- package/dist/cli/render/theme.js +57 -0
- package/dist/cli/render/theme.js.map +1 -0
- package/dist/cli/search-select.d.ts +17 -0
- package/dist/cli/search-select.d.ts.map +1 -0
- package/dist/cli/search-select.js +29 -0
- package/dist/cli/search-select.js.map +1 -0
- package/dist/cli/telemetry-contract.d.ts +146 -0
- package/dist/cli/telemetry-contract.d.ts.map +1 -0
- package/dist/cli/telemetry-contract.js +56 -0
- package/dist/cli/telemetry-contract.js.map +1 -0
- package/dist/cli/telemetry.d.ts +60 -0
- package/dist/cli/telemetry.d.ts.map +1 -0
- package/dist/cli/telemetry.js +621 -0
- package/dist/cli/telemetry.js.map +1 -0
- package/dist/cli/update-check.d.ts +21 -0
- package/dist/cli/update-check.d.ts.map +1 -0
- package/dist/cli/update-check.js +86 -0
- package/dist/cli/update-check.js.map +1 -0
- package/dist/connectors/index.d.ts +2 -0
- package/dist/connectors/index.d.ts.map +1 -0
- package/dist/connectors/index.js +2 -0
- package/dist/connectors/index.js.map +1 -0
- package/dist/connectors/registry.d.ts +55 -0
- package/dist/connectors/registry.d.ts.map +1 -0
- package/dist/connectors/registry.js +301 -0
- package/dist/connectors/registry.js.map +1 -0
- package/dist/core/cli-types.d.ts +722 -0
- package/dist/core/cli-types.d.ts.map +1 -0
- package/dist/core/cli-types.js +328 -0
- package/dist/core/cli-types.js.map +1 -0
- package/dist/core/constants.d.ts +33 -0
- package/dist/core/constants.d.ts.map +1 -0
- package/dist/core/constants.js +25 -0
- package/dist/core/constants.js.map +1 -0
- package/dist/core/errors.d.ts +47 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +50 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/grants.d.ts +19 -0
- package/dist/core/grants.d.ts.map +1 -0
- package/dist/core/grants.js +31 -0
- package/dist/core/grants.js.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/paths.d.ts +30 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/paths.js +95 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/state-store.d.ts +44 -0
- package/dist/core/state-store.d.ts.map +1 -0
- package/dist/core/state-store.js +101 -0
- package/dist/core/state-store.js.map +1 -0
- package/dist/core/types.d.ts +148 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/personal-server/client.d.ts +34 -0
- package/dist/personal-server/client.d.ts.map +1 -0
- package/dist/personal-server/client.js +108 -0
- package/dist/personal-server/client.js.map +1 -0
- package/dist/personal-server/index.d.ts +28 -0
- package/dist/personal-server/index.d.ts.map +1 -0
- package/dist/personal-server/index.js +157 -0
- package/dist/personal-server/index.js.map +1 -0
- package/dist/personal-server/scope-resolver.d.ts +22 -0
- package/dist/personal-server/scope-resolver.d.ts.map +1 -0
- package/dist/personal-server/scope-resolver.js +80 -0
- package/dist/personal-server/scope-resolver.js.map +1 -0
- package/dist/react/ConnectButton.d.ts +27 -0
- package/dist/react/ConnectButton.d.ts.map +1 -0
- package/dist/react/ConnectButton.js +39 -0
- package/dist/react/ConnectButton.js.map +1 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/useVanaConnect.d.ts +50 -0
- package/dist/react/useVanaConnect.d.ts.map +1 -0
- package/dist/react/useVanaConnect.js +114 -0
- package/dist/react/useVanaConnect.js.map +1 -0
- package/dist/react/useVanaData.d.ts +69 -0
- package/dist/react/useVanaData.d.ts.map +1 -0
- package/dist/react/useVanaData.js +144 -0
- package/dist/react/useVanaData.js.map +1 -0
- package/dist/runtime/core/contracts.d.ts +84 -0
- package/dist/runtime/core/contracts.d.ts.map +1 -0
- package/dist/runtime/core/contracts.js +2 -0
- package/dist/runtime/core/contracts.js.map +1 -0
- package/dist/runtime/core/index.d.ts +2 -0
- package/dist/runtime/core/index.d.ts.map +1 -0
- package/dist/runtime/core/index.js +2 -0
- package/dist/runtime/core/index.js.map +1 -0
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +3 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/managed-playwright.d.ts +42 -0
- package/dist/runtime/managed-playwright.d.ts.map +1 -0
- package/dist/runtime/managed-playwright.js +178 -0
- package/dist/runtime/managed-playwright.js.map +1 -0
- package/dist/runtime/playwright/browser.d.ts +12 -0
- package/dist/runtime/playwright/browser.d.ts.map +1 -0
- package/dist/runtime/playwright/browser.js +229 -0
- package/dist/runtime/playwright/browser.js.map +1 -0
- package/dist/runtime/playwright/in-process-run.d.ts +6 -0
- package/dist/runtime/playwright/in-process-run.d.ts.map +1 -0
- package/dist/runtime/playwright/in-process-run.js +625 -0
- package/dist/runtime/playwright/in-process-run.js.map +1 -0
- package/dist/runtime/playwright/index.d.ts +3 -0
- package/dist/runtime/playwright/index.d.ts.map +1 -0
- package/dist/runtime/playwright/index.js +3 -0
- package/dist/runtime/playwright/index.js.map +1 -0
- package/dist/runtime/repo-paths.d.ts +2 -0
- package/dist/runtime/repo-paths.d.ts.map +1 -0
- package/dist/runtime/repo-paths.js +36 -0
- package/dist/runtime/repo-paths.js.map +1 -0
- package/dist/server/config.d.ts +38 -0
- package/dist/server/config.d.ts.map +1 -0
- package/dist/server/config.js +45 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/connect.d.ts +43 -0
- package/dist/server/connect.d.ts.map +1 -0
- package/dist/server/connect.js +115 -0
- package/dist/server/connect.js.map +1 -0
- package/dist/server/data-client.d.ts +31 -0
- package/dist/server/data-client.d.ts.map +1 -0
- package/dist/server/data-client.js +95 -0
- package/dist/server/data-client.js.map +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +8 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/manifest-signer.d.ts +25 -0
- package/dist/server/manifest-signer.d.ts.map +1 -0
- package/dist/server/manifest-signer.js +51 -0
- package/dist/server/manifest-signer.js.map +1 -0
- package/dist/server/request-signer.d.ts +31 -0
- package/dist/server/request-signer.d.ts.map +1 -0
- package/dist/server/request-signer.js +72 -0
- package/dist/server/request-signer.js.map +1 -0
- package/dist/server/session-relay.d.ts +39 -0
- package/dist/server/session-relay.d.ts.map +1 -0
- package/dist/server/session-relay.js +74 -0
- package/dist/server/session-relay.js.map +1 -0
- package/dist/server/webhook.d.ts +11 -0
- package/dist/server/webhook.d.ts.map +1 -0
- package/dist/server/webhook.js +18 -0
- package/dist/server/webhook.js.map +1 -0
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +3 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/paths.d.ts +33 -0
- package/dist/skills/paths.d.ts.map +1 -0
- package/dist/skills/paths.js +81 -0
- package/dist/skills/paths.js.map +1 -0
- package/dist/skills/registry.d.ts +48 -0
- package/dist/skills/registry.d.ts.map +1 -0
- package/dist/skills/registry.js +173 -0
- package/dist/skills/registry.js.map +1 -0
- package/package.json +147 -0
|
@@ -0,0 +1,4703 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { spawn, execSync } from "node:child_process";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { confirm, input, password } from "@inquirer/prompts";
|
|
8
|
+
import { searchSelect } from "./search-select.js";
|
|
9
|
+
import { Command, CommanderError } from "commander";
|
|
10
|
+
// Vana-branded theme for inquirer prompts — matches brand palette
|
|
11
|
+
const VANA_BLUE = "\x1b[38;2;65;65;252m";
|
|
12
|
+
const VANA_GREEN = "\x1b[38;2;0;213;11m";
|
|
13
|
+
const VANA_MUTED = "\x1b[38;2;112;112;112m";
|
|
14
|
+
const RESET = "\x1b[0m";
|
|
15
|
+
const BOLD = "\x1b[1m";
|
|
16
|
+
const BOLD_RESET = "\x1b[22m";
|
|
17
|
+
const vanaPromptTheme = {
|
|
18
|
+
theme: {
|
|
19
|
+
prefix: { idle: `${VANA_BLUE}?${RESET}`, done: `${VANA_GREEN}✓${RESET}` },
|
|
20
|
+
style: {
|
|
21
|
+
answer: (text) => `${BOLD}${text}${BOLD_RESET}`,
|
|
22
|
+
message: (text, status) => status === "done" ? `${VANA_MUTED}${text}${RESET}` : text,
|
|
23
|
+
highlight: (text) => `${VANA_BLUE}${text}${RESET}`,
|
|
24
|
+
help: (text) => `${VANA_MUTED}${text}${RESET}`,
|
|
25
|
+
error: (text) => `\x1b[38;2;231;0;11m${text}${RESET}`,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
import { createConnectRenderer, createHumanRenderer, createLoginRenderer, formatDisplayPath, formatRelativeTime, } from "./render/index.js";
|
|
30
|
+
import { CliOutcomeStatus, migrateLegacyDataHome, getBrowserProfilesDir, getConnectorCacheDir, getLogsDir, getSessionsDir, getSourceResultPath, readCliState, readCliConfig, updateCliConfig, updateSourceState, } from "../core/index.js";
|
|
31
|
+
import { fetchConnectorToCache, listAvailableSources, readCachedConnectorMetadata, } from "../connectors/registry.js";
|
|
32
|
+
import { detectPersonalServerTarget, ingestResult, resolvePersonalServerAuthConfig, } from "../personal-server/index.js";
|
|
33
|
+
import { findDataConnectorsDir, ManagedPlaywrightRuntime, } from "../runtime/index.js";
|
|
34
|
+
import { listAvailableSkills, installSkill, readInstalledSkills, } from "../skills/index.js";
|
|
35
|
+
import { queryStatus, querySources, queryDataList, queryDataShow, queryDoctor, } from "./queries.js";
|
|
36
|
+
import { checkForUpdate, readUpdateCheck, isNewerVersion, } from "./update-check.js";
|
|
37
|
+
import { loadCredentials, saveCredentials, clearCredentials, isExpired, formatAddress, formatExpiresIn, getAuthTarget, resolvePersonalServerUrl, runDeviceCodeFlow, runSelfHostedLoginFlow, } from "./auth.js";
|
|
38
|
+
import { createCliTelemetrySession, flushTelemetryOutbox, getActiveTelemetrySession, getTelemetryStatus, setActiveTelemetrySession, setTelemetryEnabled, trackActiveTelemetryEvent, } from "./telemetry.js";
|
|
39
|
+
function cleanDescription(desc) {
|
|
40
|
+
return desc
|
|
41
|
+
.replace(/ using Playwright browser automation\.?/i, ".")
|
|
42
|
+
.replace(/^Exports\b\s*(your\s+)?/i, "Your ");
|
|
43
|
+
}
|
|
44
|
+
const require = createRequire(import.meta.url);
|
|
45
|
+
export async function runCli(argv = process.argv) {
|
|
46
|
+
// Migrate ~/.dataconnect → ~/.vana, symlink old path for DataConnect compat
|
|
47
|
+
if (migrateLegacyDataHome()) {
|
|
48
|
+
process.stderr.write("Moved your data to ~/.vana.\n\n");
|
|
49
|
+
}
|
|
50
|
+
const normalizedArgv = normalizeArgv(argv);
|
|
51
|
+
if (normalizedArgv.length <= 2) {
|
|
52
|
+
normalizedArgv.push("--help");
|
|
53
|
+
}
|
|
54
|
+
const parsedOptions = extractGlobalOptions(normalizedArgv);
|
|
55
|
+
const cliVersion = getCliVersion();
|
|
56
|
+
const installMethod = getCliInstallMethod();
|
|
57
|
+
const telemetryBaseContext = {
|
|
58
|
+
cliVersion,
|
|
59
|
+
channel: getCliChannel(cliVersion),
|
|
60
|
+
installMethod,
|
|
61
|
+
options: parsedOptions,
|
|
62
|
+
};
|
|
63
|
+
// Non-blocking update check — compute suppression flags early
|
|
64
|
+
const shouldNotify = !parsedOptions.json &&
|
|
65
|
+
process.stdout.isTTY &&
|
|
66
|
+
installMethod !== "development" &&
|
|
67
|
+
!process.env.VANA_NO_UPDATE_NOTIFIER &&
|
|
68
|
+
!process.env.CI &&
|
|
69
|
+
!process.env.AGENT &&
|
|
70
|
+
!process.env.VANA_DETACHED;
|
|
71
|
+
// Fire update check concurrently — no await, runs in the background.
|
|
72
|
+
// If it finishes before the command completes, the cache is written and
|
|
73
|
+
// the SAME run can read it for the notification below.
|
|
74
|
+
const updateCheckPromise = shouldNotify
|
|
75
|
+
? checkForUpdate(cliVersion, installMethod).catch(() => { })
|
|
76
|
+
: undefined;
|
|
77
|
+
const program = new Command();
|
|
78
|
+
program
|
|
79
|
+
.name("vana")
|
|
80
|
+
.description("Connect sources, collect data, and inspect it locally.")
|
|
81
|
+
.version(cliVersion, "-v, --version", "Print CLI version")
|
|
82
|
+
.showSuggestionAfterError(true)
|
|
83
|
+
.addHelpText("after", `
|
|
84
|
+
Quick start:
|
|
85
|
+
vana login Log in to your Vana account
|
|
86
|
+
vana connect Connect a source and collect data
|
|
87
|
+
vana sources Browse available sources
|
|
88
|
+
vana status Check system health
|
|
89
|
+
|
|
90
|
+
Data:
|
|
91
|
+
vana data list List collected datasets
|
|
92
|
+
vana data show <src> Inspect a dataset
|
|
93
|
+
|
|
94
|
+
Server:
|
|
95
|
+
vana server Personal Server status and management
|
|
96
|
+
|
|
97
|
+
Agent:
|
|
98
|
+
vana mcp Start MCP server (for Claude Code, Cursor, etc.)
|
|
99
|
+
vana skills list List available agent skills
|
|
100
|
+
vana skills install Install a skill for your agent
|
|
101
|
+
|
|
102
|
+
Background:
|
|
103
|
+
vana connect <src> --detach Connect in the background
|
|
104
|
+
vana schedule add Schedule daily collection
|
|
105
|
+
vana schedule list Show scheduled tasks
|
|
106
|
+
|
|
107
|
+
More:
|
|
108
|
+
vana doctor Detailed diagnostics
|
|
109
|
+
vana logs [source] View run logs
|
|
110
|
+
vana setup Install or repair runtime
|
|
111
|
+
`);
|
|
112
|
+
program.exitOverride();
|
|
113
|
+
program
|
|
114
|
+
.command("version")
|
|
115
|
+
.description("Print CLI version")
|
|
116
|
+
.option("--json", "Output machine-readable JSON")
|
|
117
|
+
.action(async () => {
|
|
118
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "version" }, async () => {
|
|
119
|
+
if (parsedOptions.json) {
|
|
120
|
+
process.stdout.write(`${JSON.stringify({
|
|
121
|
+
cliVersion,
|
|
122
|
+
channel: getCliChannel(cliVersion),
|
|
123
|
+
installMethod: getCliInstallMethod(),
|
|
124
|
+
})}\n`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
process.stdout.write(`${cliVersion} (${getCliChannel(cliVersion)}, ${formatInstallMethodLabel(getCliInstallMethod()).toLowerCase()})\n`);
|
|
128
|
+
return 0;
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
const connectCommand = program
|
|
132
|
+
.command("connect [source]")
|
|
133
|
+
.description("Connect a source and collect data")
|
|
134
|
+
.option("--json", "Output machine-readable JSON")
|
|
135
|
+
.option("--no-input", "Fail instead of prompting for input")
|
|
136
|
+
.option("--ipc", "Use file-based IPC for credential prompts (for agents)")
|
|
137
|
+
.option("--yes", "Approve safe setup prompts automatically")
|
|
138
|
+
.option("--quiet", "Reduce non-essential output")
|
|
139
|
+
.option("--detach", "Run in the background")
|
|
140
|
+
.action(async (source) => {
|
|
141
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "connect", source }, async () => {
|
|
142
|
+
if (parsedOptions.detach && source) {
|
|
143
|
+
return runDetached("connect", source, parsedOptions);
|
|
144
|
+
}
|
|
145
|
+
return source
|
|
146
|
+
? runConnect(source, parsedOptions)
|
|
147
|
+
: runConnectEntry(parsedOptions);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
connectCommand.addHelpText("after", `
|
|
151
|
+
Examples:
|
|
152
|
+
vana connect
|
|
153
|
+
vana connect github
|
|
154
|
+
vana connect github --json --no-input
|
|
155
|
+
vana connect github --json --ipc
|
|
156
|
+
`);
|
|
157
|
+
const sourcesCommand = program
|
|
158
|
+
.command("sources [source]")
|
|
159
|
+
.description("List supported sources, or show detail for one source")
|
|
160
|
+
.option("--json", "Output machine-readable JSON")
|
|
161
|
+
.action(async (source) => {
|
|
162
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "sources", source }, async () => source
|
|
163
|
+
? runSourceDetail(source, parsedOptions)
|
|
164
|
+
: runList(parsedOptions));
|
|
165
|
+
});
|
|
166
|
+
sourcesCommand.addHelpText("after", `
|
|
167
|
+
Examples:
|
|
168
|
+
vana sources
|
|
169
|
+
vana sources github
|
|
170
|
+
vana sources --json | jq '.sources'
|
|
171
|
+
`);
|
|
172
|
+
const collectCommand = program
|
|
173
|
+
.command("collect [source]")
|
|
174
|
+
.description("Re-collect data from a previously connected source")
|
|
175
|
+
.option("--json", "Output machine-readable JSON")
|
|
176
|
+
.option("--no-input", "Fail instead of prompting for input")
|
|
177
|
+
.option("--ipc", "Use file-based IPC for credential prompts (for agents)")
|
|
178
|
+
.option("--yes", "Approve safe setup prompts automatically")
|
|
179
|
+
.option("--quiet", "Reduce non-essential output")
|
|
180
|
+
.option("--detach", "Run in the background")
|
|
181
|
+
.option("--all", "Collect from all connected sources")
|
|
182
|
+
.action(async (source) => {
|
|
183
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "collect", source }, async () => {
|
|
184
|
+
if (parsedOptions.detach && source) {
|
|
185
|
+
return runDetached("collect", source, parsedOptions);
|
|
186
|
+
}
|
|
187
|
+
return source
|
|
188
|
+
? runCollect(source, parsedOptions)
|
|
189
|
+
: runCollectAll(parsedOptions);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
collectCommand.addHelpText("after", `
|
|
193
|
+
Examples:
|
|
194
|
+
vana collect github
|
|
195
|
+
vana collect
|
|
196
|
+
vana collect --json
|
|
197
|
+
`);
|
|
198
|
+
const statusCommand = program
|
|
199
|
+
.command("status")
|
|
200
|
+
.description("Show runtime and Personal Server status")
|
|
201
|
+
.option("--json", "Output machine-readable JSON")
|
|
202
|
+
.action(async () => {
|
|
203
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "status" }, async () => runStatus(parsedOptions));
|
|
204
|
+
});
|
|
205
|
+
statusCommand.addHelpText("after", `
|
|
206
|
+
Examples:
|
|
207
|
+
vana status
|
|
208
|
+
vana status --json | jq
|
|
209
|
+
`);
|
|
210
|
+
const doctorCommand = program
|
|
211
|
+
.command("doctor")
|
|
212
|
+
.description("Inspect local CLI, runtime, and install health")
|
|
213
|
+
.option("--json", "Output machine-readable JSON")
|
|
214
|
+
.action(async () => {
|
|
215
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "doctor" }, async () => runDoctor(parsedOptions));
|
|
216
|
+
});
|
|
217
|
+
doctorCommand.addHelpText("after", `
|
|
218
|
+
Examples:
|
|
219
|
+
vana doctor
|
|
220
|
+
vana doctor --json | jq
|
|
221
|
+
`);
|
|
222
|
+
const setupCommand = program
|
|
223
|
+
.command("setup")
|
|
224
|
+
.description("Install or repair the local runtime")
|
|
225
|
+
.option("--json", "Output machine-readable JSON")
|
|
226
|
+
.option("--yes", "Approve safe setup prompts automatically")
|
|
227
|
+
.action(async () => {
|
|
228
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "setup" }, async () => runSetup(parsedOptions));
|
|
229
|
+
});
|
|
230
|
+
setupCommand.addHelpText("after", `
|
|
231
|
+
Examples:
|
|
232
|
+
vana setup
|
|
233
|
+
vana setup --yes
|
|
234
|
+
`);
|
|
235
|
+
const data = program
|
|
236
|
+
.command("data")
|
|
237
|
+
.description("Inspect collected datasets, paths, and summaries");
|
|
238
|
+
data.addHelpText("after", `
|
|
239
|
+
Examples:
|
|
240
|
+
vana data list
|
|
241
|
+
vana data show github
|
|
242
|
+
vana data path github --json
|
|
243
|
+
`);
|
|
244
|
+
data.action(() => {
|
|
245
|
+
data.outputHelp();
|
|
246
|
+
process.exitCode = 0;
|
|
247
|
+
});
|
|
248
|
+
const dataListCommand = data
|
|
249
|
+
.command("list")
|
|
250
|
+
.description("List locally available collected datasets")
|
|
251
|
+
.option("--json", "Output machine-readable JSON")
|
|
252
|
+
.action(async () => {
|
|
253
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "data", subcommand: "list" }, async () => runDataList(parsedOptions));
|
|
254
|
+
});
|
|
255
|
+
dataListCommand.addHelpText("after", `
|
|
256
|
+
Examples:
|
|
257
|
+
vana data list
|
|
258
|
+
vana data list --json | jq '.datasets'
|
|
259
|
+
`);
|
|
260
|
+
const dataShowCommand = data
|
|
261
|
+
.command("show <source>")
|
|
262
|
+
.description("Show a collected dataset")
|
|
263
|
+
.option("--json", "Output machine-readable JSON")
|
|
264
|
+
.action(async (source) => {
|
|
265
|
+
process.exitCode = await runCommandWithTelemetry({
|
|
266
|
+
...telemetryBaseContext,
|
|
267
|
+
command: "data",
|
|
268
|
+
subcommand: "show",
|
|
269
|
+
source,
|
|
270
|
+
}, async () => runDataShow(source, parsedOptions));
|
|
271
|
+
});
|
|
272
|
+
dataShowCommand.addHelpText("after", `
|
|
273
|
+
Examples:
|
|
274
|
+
vana data show github
|
|
275
|
+
vana data show github --json | jq '.summary'
|
|
276
|
+
`);
|
|
277
|
+
const dataPathCommand = data
|
|
278
|
+
.command("path <source>")
|
|
279
|
+
.description("Print the local path for a collected dataset")
|
|
280
|
+
.option("--json", "Output machine-readable JSON")
|
|
281
|
+
.action(async (source) => {
|
|
282
|
+
process.exitCode = await runCommandWithTelemetry({
|
|
283
|
+
...telemetryBaseContext,
|
|
284
|
+
command: "data",
|
|
285
|
+
subcommand: "path",
|
|
286
|
+
source,
|
|
287
|
+
}, async () => runDataPath(source, parsedOptions));
|
|
288
|
+
});
|
|
289
|
+
dataPathCommand.addHelpText("after", `
|
|
290
|
+
Examples:
|
|
291
|
+
vana data path github
|
|
292
|
+
vana data path github --json | jq -r '.path'
|
|
293
|
+
`);
|
|
294
|
+
const logsCommand = program
|
|
295
|
+
.command("logs [source]")
|
|
296
|
+
.description("Inspect stored connector run logs")
|
|
297
|
+
.option("--json", "Output machine-readable JSON")
|
|
298
|
+
.action(async (source) => {
|
|
299
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "logs", source }, async () => runLogs(source, parsedOptions));
|
|
300
|
+
});
|
|
301
|
+
logsCommand.addHelpText("after", `
|
|
302
|
+
Examples:
|
|
303
|
+
vana logs
|
|
304
|
+
vana logs github
|
|
305
|
+
vana logs github --json | jq
|
|
306
|
+
`);
|
|
307
|
+
const server = program
|
|
308
|
+
.command("server")
|
|
309
|
+
.description("Manage Personal Server connection")
|
|
310
|
+
.option("--json", "Output machine-readable JSON");
|
|
311
|
+
server.addHelpText("after", `
|
|
312
|
+
Examples:
|
|
313
|
+
vana server
|
|
314
|
+
vana server set-url http://localhost:8080
|
|
315
|
+
vana server set-url https://ps-abc123.server.vana.org
|
|
316
|
+
vana server clear-url
|
|
317
|
+
`);
|
|
318
|
+
server.action(async () => {
|
|
319
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "server", subcommand: "status" }, async () => runServerStatus(parsedOptions));
|
|
320
|
+
});
|
|
321
|
+
server
|
|
322
|
+
.command("status")
|
|
323
|
+
.description("Show Personal Server status")
|
|
324
|
+
.option("--json", "Output machine-readable JSON")
|
|
325
|
+
.action(async () => {
|
|
326
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "server", subcommand: "status" }, async () => runServerStatus(parsedOptions));
|
|
327
|
+
});
|
|
328
|
+
server
|
|
329
|
+
.command("set-url <url>")
|
|
330
|
+
.description("Save a Personal Server URL")
|
|
331
|
+
.option("--json", "Output machine-readable JSON")
|
|
332
|
+
.action(async (url) => {
|
|
333
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "server", subcommand: "set-url" }, async () => runServerSetUrl(url, parsedOptions));
|
|
334
|
+
});
|
|
335
|
+
server
|
|
336
|
+
.command("clear-url")
|
|
337
|
+
.description("Remove the saved Personal Server URL")
|
|
338
|
+
.option("--json", "Output machine-readable JSON")
|
|
339
|
+
.action(async () => {
|
|
340
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "server", subcommand: "clear-url" }, async () => runServerClearUrl(parsedOptions));
|
|
341
|
+
});
|
|
342
|
+
server
|
|
343
|
+
.command("sync")
|
|
344
|
+
.description("Sync all local-only datasets to your Personal Server")
|
|
345
|
+
.option("--json", "Output machine-readable JSON")
|
|
346
|
+
.action(async () => {
|
|
347
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "server", subcommand: "sync" }, async () => runServerSync(parsedOptions));
|
|
348
|
+
});
|
|
349
|
+
server
|
|
350
|
+
.command("data [scope]")
|
|
351
|
+
.description("List scopes stored in your Personal Server")
|
|
352
|
+
.option("--json", "Output machine-readable JSON")
|
|
353
|
+
.action(async (scope) => {
|
|
354
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "server", subcommand: "data" }, async () => runServerData(scope, parsedOptions));
|
|
355
|
+
});
|
|
356
|
+
program
|
|
357
|
+
.command("login")
|
|
358
|
+
.description("Log in to your Vana account or a self-hosted Personal Server")
|
|
359
|
+
.option("-s, --server <url>", "Self-hosted Personal Server URL")
|
|
360
|
+
.option("--client-id <id>", "OAuth public client ID for Vana Account login")
|
|
361
|
+
.action(async (loginOptions) => {
|
|
362
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "login" }, async () => runLogin(parsedOptions, loginOptions.server, loginOptions.clientId));
|
|
363
|
+
});
|
|
364
|
+
program
|
|
365
|
+
.command("logout")
|
|
366
|
+
.description("Log out and remove saved credentials")
|
|
367
|
+
.action(async () => {
|
|
368
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "logout" }, async () => runLogout(parsedOptions));
|
|
369
|
+
});
|
|
370
|
+
const telemetry = program
|
|
371
|
+
.command("telemetry")
|
|
372
|
+
.description("Inspect and manage CLI telemetry");
|
|
373
|
+
telemetry.action(async () => {
|
|
374
|
+
process.exitCode = await runTelemetryStatus(parsedOptions);
|
|
375
|
+
});
|
|
376
|
+
telemetry
|
|
377
|
+
.command("status")
|
|
378
|
+
.description("Show telemetry state")
|
|
379
|
+
.option("--json", "Output machine-readable JSON")
|
|
380
|
+
.action(async () => {
|
|
381
|
+
process.exitCode = await runTelemetryStatus(parsedOptions);
|
|
382
|
+
});
|
|
383
|
+
telemetry
|
|
384
|
+
.command("enable")
|
|
385
|
+
.description("Enable telemetry")
|
|
386
|
+
.action(async () => {
|
|
387
|
+
process.exitCode = await runTelemetryEnable(parsedOptions);
|
|
388
|
+
});
|
|
389
|
+
telemetry
|
|
390
|
+
.command("disable")
|
|
391
|
+
.description("Disable telemetry")
|
|
392
|
+
.action(async () => {
|
|
393
|
+
process.exitCode = await runTelemetryDisable(parsedOptions);
|
|
394
|
+
});
|
|
395
|
+
program
|
|
396
|
+
.command("mcp")
|
|
397
|
+
.description("Start MCP server for agent integration")
|
|
398
|
+
.action(async () => {
|
|
399
|
+
process.exitCode = await runLongRunningCommandWithTelemetry({ ...telemetryBaseContext, command: "mcp" }, async () => {
|
|
400
|
+
const { startMcpServer } = await import("./mcp-server.js");
|
|
401
|
+
await startMcpServer();
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
const skill = program
|
|
405
|
+
.command("skills")
|
|
406
|
+
.description("Manage agent skills")
|
|
407
|
+
.option("--json", "Output as JSON");
|
|
408
|
+
skill.addHelpText("after", `
|
|
409
|
+
Examples:
|
|
410
|
+
vana skills list
|
|
411
|
+
vana skills install connect-data
|
|
412
|
+
vana skills show connect-data
|
|
413
|
+
`);
|
|
414
|
+
skill.action(async () => {
|
|
415
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "skills" }, async () => runSkillsGuidedPicker(parsedOptions));
|
|
416
|
+
});
|
|
417
|
+
skill
|
|
418
|
+
.command("list")
|
|
419
|
+
.description("List available agent skills")
|
|
420
|
+
.option("--json", "Output as JSON")
|
|
421
|
+
.action(async () => {
|
|
422
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "skills", subcommand: "list" }, async () => runSkillList(parsedOptions));
|
|
423
|
+
});
|
|
424
|
+
skill
|
|
425
|
+
.command("install <name>")
|
|
426
|
+
.description("Install a skill for your agent")
|
|
427
|
+
.action(async (name) => {
|
|
428
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "skills", subcommand: "install" }, async () => runSkillInstall(name, parsedOptions));
|
|
429
|
+
});
|
|
430
|
+
skill
|
|
431
|
+
.command("show <name>")
|
|
432
|
+
.description("Show skill details")
|
|
433
|
+
.action(async (name) => {
|
|
434
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "skills", subcommand: "show" }, async () => runSkillShow(name, parsedOptions));
|
|
435
|
+
});
|
|
436
|
+
// --- Schedule commands ---
|
|
437
|
+
const schedule = program
|
|
438
|
+
.command("schedule")
|
|
439
|
+
.description("Manage scheduled data collection");
|
|
440
|
+
schedule.addHelpText("after", `
|
|
441
|
+
Examples:
|
|
442
|
+
vana schedule add
|
|
443
|
+
vana schedule add --every 12h
|
|
444
|
+
vana schedule list
|
|
445
|
+
vana schedule remove
|
|
446
|
+
`);
|
|
447
|
+
schedule.action(() => {
|
|
448
|
+
schedule.outputHelp();
|
|
449
|
+
process.exitCode = 0;
|
|
450
|
+
});
|
|
451
|
+
schedule
|
|
452
|
+
.command("add")
|
|
453
|
+
.description("Add a scheduled collection")
|
|
454
|
+
.option("--every <interval>", "Collection interval (e.g. 24h, 12h, 1h)", "24h")
|
|
455
|
+
.action(async (opts) => {
|
|
456
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "schedule", subcommand: "add" }, async () => runScheduleAdd(opts.every, parsedOptions));
|
|
457
|
+
});
|
|
458
|
+
schedule
|
|
459
|
+
.command("list")
|
|
460
|
+
.description("Show scheduled tasks")
|
|
461
|
+
.option("--json", "Output machine-readable JSON")
|
|
462
|
+
.action(async () => {
|
|
463
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "schedule", subcommand: "list" }, async () => runScheduleList(parsedOptions));
|
|
464
|
+
});
|
|
465
|
+
schedule
|
|
466
|
+
.command("remove")
|
|
467
|
+
.description("Remove the scheduled collection")
|
|
468
|
+
.action(async () => {
|
|
469
|
+
process.exitCode = await runCommandWithTelemetry({ ...telemetryBaseContext, command: "schedule", subcommand: "remove" }, async () => runScheduleRemove(parsedOptions));
|
|
470
|
+
});
|
|
471
|
+
try {
|
|
472
|
+
await program.parseAsync(normalizedArgv);
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
if (error instanceof CommanderError) {
|
|
476
|
+
if (error.code === "commander.help" ||
|
|
477
|
+
error.code === "commander.helpDisplayed" ||
|
|
478
|
+
error.code === "commander.version") {
|
|
479
|
+
process.exitCode = error.exitCode;
|
|
480
|
+
return Number(process.exitCode ?? 0);
|
|
481
|
+
}
|
|
482
|
+
// Commander already printed to stderr; just set exit code.
|
|
483
|
+
process.exitCode = error.exitCode;
|
|
484
|
+
return Number(process.exitCode ?? 1);
|
|
485
|
+
}
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
// Show update notification if a newer version is available.
|
|
489
|
+
// The concurrent check may have populated the cache during this run.
|
|
490
|
+
if (shouldNotify) {
|
|
491
|
+
try {
|
|
492
|
+
await Promise.race([
|
|
493
|
+
updateCheckPromise,
|
|
494
|
+
new Promise((resolve) => setTimeout(resolve, 2000)),
|
|
495
|
+
]);
|
|
496
|
+
const cache = await readUpdateCheck();
|
|
497
|
+
if (cache && isNewerVersion(cliVersion, cache.latestVersion)) {
|
|
498
|
+
const { upgrade } = getLifecycleCommands(installMethod, getCliChannel(cliVersion));
|
|
499
|
+
process.stderr.write(`\nUpdate available: ${cliVersion} → ${cache.latestVersion}\nRun: ${upgrade}\n`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// Never block exit for update notification failures
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return Number(process.exitCode ?? 0);
|
|
507
|
+
}
|
|
508
|
+
function classifyCommandFailure(error) {
|
|
509
|
+
if (error instanceof Error) {
|
|
510
|
+
const value = error.message.toLowerCase();
|
|
511
|
+
if (value.includes("needs_input") ||
|
|
512
|
+
value.includes("needs input") ||
|
|
513
|
+
value.includes("input required") ||
|
|
514
|
+
value.includes("manual step"))
|
|
515
|
+
return "needs_input";
|
|
516
|
+
if (value.includes("prompt_cancelled") || value.includes("cancelled"))
|
|
517
|
+
return "prompt_cancelled";
|
|
518
|
+
if (value.includes("personal_server_unavailable") ||
|
|
519
|
+
value.includes("personal server unavailable"))
|
|
520
|
+
return "personal_server_unavailable";
|
|
521
|
+
if (value.includes("auth expired"))
|
|
522
|
+
return "auth_expired";
|
|
523
|
+
if (value.includes("auth"))
|
|
524
|
+
return "auth_failed";
|
|
525
|
+
if (value.includes("setup"))
|
|
526
|
+
return "setup_required";
|
|
527
|
+
if (value.includes("timeout") || value.includes("timed out"))
|
|
528
|
+
return "timeout";
|
|
529
|
+
if (value.includes("network"))
|
|
530
|
+
return "network_error";
|
|
531
|
+
if (value.includes("runtime"))
|
|
532
|
+
return "runtime_error";
|
|
533
|
+
if (value.includes("connector"))
|
|
534
|
+
return "connector_unavailable";
|
|
535
|
+
if (value.includes("ingest"))
|
|
536
|
+
return "ingest_failed";
|
|
537
|
+
}
|
|
538
|
+
return "unknown";
|
|
539
|
+
}
|
|
540
|
+
async function runCommandWithTelemetry(context, action) {
|
|
541
|
+
const session = await createCliTelemetrySession({
|
|
542
|
+
...context,
|
|
543
|
+
options: {
|
|
544
|
+
json: Boolean(context.options.json),
|
|
545
|
+
noInput: Boolean(context.options.noInput),
|
|
546
|
+
quiet: Boolean(context.options.quiet),
|
|
547
|
+
detach: Boolean(context.options.detach),
|
|
548
|
+
ipc: Boolean(context.options.ipc),
|
|
549
|
+
},
|
|
550
|
+
localOnly: context.localOnly,
|
|
551
|
+
});
|
|
552
|
+
setActiveTelemetrySession(session);
|
|
553
|
+
await flushTelemetryOutbox();
|
|
554
|
+
try {
|
|
555
|
+
const exitCode = await action();
|
|
556
|
+
session.markCommandResult({ exitCode });
|
|
557
|
+
return exitCode;
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
session.markCommandResult({
|
|
561
|
+
exitCode: 1,
|
|
562
|
+
errorClass: classifyCommandFailure(error),
|
|
563
|
+
});
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
await session.persist();
|
|
568
|
+
await session.flush();
|
|
569
|
+
setActiveTelemetrySession(null);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async function runLongRunningCommandWithTelemetry(context, action) {
|
|
573
|
+
const session = await createCliTelemetrySession({
|
|
574
|
+
...context,
|
|
575
|
+
options: {
|
|
576
|
+
json: Boolean(context.options.json),
|
|
577
|
+
noInput: Boolean(context.options.noInput),
|
|
578
|
+
quiet: Boolean(context.options.quiet),
|
|
579
|
+
detach: Boolean(context.options.detach),
|
|
580
|
+
ipc: Boolean(context.options.ipc),
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
setActiveTelemetrySession(session);
|
|
584
|
+
await flushTelemetryOutbox();
|
|
585
|
+
session.trackCustomEvent("mcp_started");
|
|
586
|
+
session.markCommandResult({ exitCode: 0, outcome: "started" });
|
|
587
|
+
await session.persist();
|
|
588
|
+
await session.flush();
|
|
589
|
+
try {
|
|
590
|
+
await action();
|
|
591
|
+
return 0;
|
|
592
|
+
}
|
|
593
|
+
finally {
|
|
594
|
+
setActiveTelemetrySession(null);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
async function runTelemetryStatus(options) {
|
|
598
|
+
const status = await getTelemetryStatus();
|
|
599
|
+
const endpointHost = (() => {
|
|
600
|
+
try {
|
|
601
|
+
return new URL(status.endpoint).host;
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
return status.endpoint;
|
|
605
|
+
}
|
|
606
|
+
})();
|
|
607
|
+
if (options.json) {
|
|
608
|
+
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
609
|
+
return 0;
|
|
610
|
+
}
|
|
611
|
+
const emit = createEmitter(options);
|
|
612
|
+
emit.title("Telemetry");
|
|
613
|
+
emit.blank();
|
|
614
|
+
emit.keyValue("Enabled", status.enabled ? "yes" : "no");
|
|
615
|
+
emit.keyValue("Mode", status.mode);
|
|
616
|
+
emit.keyValue("Reason", status.reason.replaceAll("_", " "));
|
|
617
|
+
emit.keyValue("Endpoint", endpointHost);
|
|
618
|
+
emit.keyValue("Queued", String(status.queuedBatches));
|
|
619
|
+
emit.detail("Collected data stays local. Remote telemetry only includes small operational events.");
|
|
620
|
+
if (status.enabled) {
|
|
621
|
+
emit.detail(`Disable with: ${emit.code("vana telemetry disable")}`);
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
emit.detail(`Enable with: ${emit.code("vana telemetry enable")}`);
|
|
625
|
+
}
|
|
626
|
+
if (process.env.VANA_TELEMETRY_DEBUG === "1") {
|
|
627
|
+
emit.detail(`Debug mode is active via ${emit.code("VANA_TELEMETRY_DEBUG=1")}. Events print to stderr and are not uploaded.`);
|
|
628
|
+
}
|
|
629
|
+
if (process.env.VANA_TELEMETRY_DISABLED === "1") {
|
|
630
|
+
emit.detail(`Telemetry is currently overridden by ${emit.code("VANA_TELEMETRY_DISABLED=1")}.`);
|
|
631
|
+
}
|
|
632
|
+
return 0;
|
|
633
|
+
}
|
|
634
|
+
async function runTelemetryEnable(options) {
|
|
635
|
+
await setTelemetryEnabled(true);
|
|
636
|
+
if (options.json) {
|
|
637
|
+
process.stdout.write(`${JSON.stringify({ enabled: true })}\n`);
|
|
638
|
+
return 0;
|
|
639
|
+
}
|
|
640
|
+
const emit = createEmitter(options);
|
|
641
|
+
emit.success("Telemetry enabled.");
|
|
642
|
+
if (process.env.VANA_TELEMETRY_DISABLED === "1") {
|
|
643
|
+
emit.detail(`The current shell still disables uploads via ${emit.code("VANA_TELEMETRY_DISABLED=1")}.`);
|
|
644
|
+
}
|
|
645
|
+
return 0;
|
|
646
|
+
}
|
|
647
|
+
async function runTelemetryDisable(options) {
|
|
648
|
+
await setTelemetryEnabled(false);
|
|
649
|
+
if (options.json) {
|
|
650
|
+
process.stdout.write(`${JSON.stringify({ enabled: false })}\n`);
|
|
651
|
+
return 0;
|
|
652
|
+
}
|
|
653
|
+
const emit = createEmitter(options);
|
|
654
|
+
emit.success("Telemetry disabled.");
|
|
655
|
+
return 0;
|
|
656
|
+
}
|
|
657
|
+
async function runConnect(rawSource, options) {
|
|
658
|
+
const source = rawSource.toLowerCase();
|
|
659
|
+
const runtime = new ManagedPlaywrightRuntime();
|
|
660
|
+
const emit = createEmitter(options);
|
|
661
|
+
const renderer = !options.json && !options.quiet ? createConnectRenderer() : null;
|
|
662
|
+
const registrySources = await loadRegistrySources();
|
|
663
|
+
const sourceLabels = createSourceLabelMap(registrySources);
|
|
664
|
+
const displayName = displaySource(source, sourceLabels);
|
|
665
|
+
let setupLogPath;
|
|
666
|
+
let fetchLogPath;
|
|
667
|
+
let runLogPath;
|
|
668
|
+
let pendingExitCode = null;
|
|
669
|
+
try {
|
|
670
|
+
// Title
|
|
671
|
+
renderer?.title(displayName);
|
|
672
|
+
const target = await detectPersonalServerTarget();
|
|
673
|
+
// --- Phase 1: Runtime check (silent if installed) ---
|
|
674
|
+
if (runtime.state !== "installed") {
|
|
675
|
+
if (options.noInput) {
|
|
676
|
+
emit.event({
|
|
677
|
+
type: "outcome",
|
|
678
|
+
status: CliOutcomeStatus.SETUP_REQUIRED,
|
|
679
|
+
source,
|
|
680
|
+
});
|
|
681
|
+
renderer?.fail(`${displayName} needs a local browser runtime. Run without --no-input to install.`);
|
|
682
|
+
return 1;
|
|
683
|
+
}
|
|
684
|
+
if (!options.yes) {
|
|
685
|
+
renderer?.cleanup();
|
|
686
|
+
process.stderr.write("\n");
|
|
687
|
+
process.stderr.write("Vana Connect needs a local browser runtime.\n\n");
|
|
688
|
+
process.stderr.write("This will install:\n");
|
|
689
|
+
process.stderr.write(" \u2022 Connector runner\n");
|
|
690
|
+
process.stderr.write(" \u2022 Chromium browser engine\n");
|
|
691
|
+
process.stderr.write(" \u2022 Local files under ~/.vana/\n\n");
|
|
692
|
+
process.stderr.write("Your credentials stay on this machine.\n\n");
|
|
693
|
+
const shouldContinue = await confirm({
|
|
694
|
+
message: "Continue?",
|
|
695
|
+
default: true,
|
|
696
|
+
...vanaPromptTheme,
|
|
697
|
+
});
|
|
698
|
+
if (!shouldContinue) {
|
|
699
|
+
renderer?.fail("Cancelled.");
|
|
700
|
+
emit.event({
|
|
701
|
+
type: "outcome",
|
|
702
|
+
status: CliOutcomeStatus.SETUP_REQUIRED,
|
|
703
|
+
source,
|
|
704
|
+
reason: "setup_declined",
|
|
705
|
+
});
|
|
706
|
+
return 1;
|
|
707
|
+
}
|
|
708
|
+
process.stderr.write("\n");
|
|
709
|
+
}
|
|
710
|
+
trackActiveTelemetryEvent("runtime_install_started", { source });
|
|
711
|
+
let installResult;
|
|
712
|
+
try {
|
|
713
|
+
installResult = await runtime.ensureInstalled(Boolean(options.yes));
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
trackActiveTelemetryEvent("runtime_install_failed", {
|
|
717
|
+
source,
|
|
718
|
+
errorClass: classifyCommandFailure(error),
|
|
719
|
+
});
|
|
720
|
+
throw error;
|
|
721
|
+
}
|
|
722
|
+
setupLogPath = installResult.logPath;
|
|
723
|
+
emit.event({
|
|
724
|
+
type: "setup-complete",
|
|
725
|
+
runtime: installResult.runtime,
|
|
726
|
+
logPath: installResult.logPath,
|
|
727
|
+
});
|
|
728
|
+
renderer?.scopeDone("Runtime ready");
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
emit.event({
|
|
732
|
+
type: "setup-check",
|
|
733
|
+
runtime: runtime.state,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
// --- Phase 2: Connector fetch (silent if cached/fast) ---
|
|
737
|
+
const preState = await readCliState();
|
|
738
|
+
const currentVersion = preState.sources[source]?.connectorVersion;
|
|
739
|
+
let fetched;
|
|
740
|
+
try {
|
|
741
|
+
fetched = await runtime.fetchConnector(source, currentVersion);
|
|
742
|
+
}
|
|
743
|
+
catch (firstError) {
|
|
744
|
+
const firstMessage = firstError instanceof Error ? firstError.message : "";
|
|
745
|
+
const isChecksumError = firstMessage.toLowerCase().includes("checksum") ||
|
|
746
|
+
firstMessage.toLowerCase().includes("mismatch");
|
|
747
|
+
// Auto-retry on stale cache: clear cached connector and re-fetch
|
|
748
|
+
// from remote (skip local data-connectors dir which may be stale).
|
|
749
|
+
if (isChecksumError) {
|
|
750
|
+
try {
|
|
751
|
+
const cacheDir = getConnectorCacheDir();
|
|
752
|
+
const sourceCacheDir = path.join(cacheDir, source);
|
|
753
|
+
await fsp.rm(sourceCacheDir, { recursive: true, force: true });
|
|
754
|
+
const resolution = await fetchConnectorToCache(source, cacheDir, undefined);
|
|
755
|
+
fetched = {
|
|
756
|
+
connectorPath: resolution.connectorPath,
|
|
757
|
+
logPath: "",
|
|
758
|
+
version: resolution.version,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
catch (retryError) {
|
|
762
|
+
const retryMessage = retryError instanceof Error
|
|
763
|
+
? retryError.message
|
|
764
|
+
: `Could not fetch ${displayName} connector.`;
|
|
765
|
+
const message = formatHumanSourceMessage(retryMessage, source, displayName);
|
|
766
|
+
await updateSourceState(source, {
|
|
767
|
+
connectorInstalled: false,
|
|
768
|
+
lastRunAt: new Date().toISOString(),
|
|
769
|
+
lastRunOutcome: CliOutcomeStatus.CONNECTOR_UNAVAILABLE,
|
|
770
|
+
dataState: "none",
|
|
771
|
+
lastError: message,
|
|
772
|
+
lastResultPath: null,
|
|
773
|
+
lastLogPath: getErrorLogPath(retryError),
|
|
774
|
+
});
|
|
775
|
+
renderer?.fail(`${displayName} connector could not be verified.`);
|
|
776
|
+
renderer?.detail(`Try again later, or report: https://github.com/vana-com/data-connectors/issues`);
|
|
777
|
+
emit.event({
|
|
778
|
+
type: "outcome",
|
|
779
|
+
status: CliOutcomeStatus.CONNECTOR_UNAVAILABLE,
|
|
780
|
+
source,
|
|
781
|
+
reason: message,
|
|
782
|
+
});
|
|
783
|
+
return 1;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
const message = formatHumanSourceMessage(firstMessage ||
|
|
788
|
+
`No connector is available for ${displayName} right now.`, source, displayName);
|
|
789
|
+
await updateSourceState(source, {
|
|
790
|
+
connectorInstalled: false,
|
|
791
|
+
lastRunAt: new Date().toISOString(),
|
|
792
|
+
lastRunOutcome: CliOutcomeStatus.CONNECTOR_UNAVAILABLE,
|
|
793
|
+
dataState: "none",
|
|
794
|
+
lastError: message,
|
|
795
|
+
lastResultPath: null,
|
|
796
|
+
lastLogPath: getErrorLogPath(firstError),
|
|
797
|
+
});
|
|
798
|
+
renderer?.fail(`${displayName} is not available.`);
|
|
799
|
+
renderer?.detail(`See what’s ready: vana sources`);
|
|
800
|
+
emit.event({
|
|
801
|
+
type: "outcome",
|
|
802
|
+
status: CliOutcomeStatus.CONNECTOR_UNAVAILABLE,
|
|
803
|
+
source,
|
|
804
|
+
reason: message,
|
|
805
|
+
});
|
|
806
|
+
return 1;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (fetched.updated && fetched.previousVersion) {
|
|
810
|
+
trackActiveTelemetryEvent("connector_update_applied", {
|
|
811
|
+
source,
|
|
812
|
+
metadata: {
|
|
813
|
+
previousVersion: fetched.previousVersion,
|
|
814
|
+
connectorVersion: fetched.version,
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
renderer?.detail(`Updated connector (${fetched.previousVersion} → ${fetched.version}).`);
|
|
818
|
+
}
|
|
819
|
+
fetchLogPath = fetched.logPath;
|
|
820
|
+
const sourceDetails = registrySources.find((item) => item.id === source);
|
|
821
|
+
const resolution = {
|
|
822
|
+
source,
|
|
823
|
+
connectorPath: fetched.connectorPath,
|
|
824
|
+
};
|
|
825
|
+
emit.event({
|
|
826
|
+
type: "connector-resolved",
|
|
827
|
+
source: resolution.source,
|
|
828
|
+
connectorPath: resolution.connectorPath,
|
|
829
|
+
logPath: fetched.logPath,
|
|
830
|
+
});
|
|
831
|
+
trackActiveTelemetryEvent("connector_version_detected", {
|
|
832
|
+
source: resolution.source,
|
|
833
|
+
connectorVersion: fetched.version,
|
|
834
|
+
});
|
|
835
|
+
// --- Phase 3: Pre-connection validation (silent) ---
|
|
836
|
+
const profilePath = path.join(getBrowserProfilesDir(), `${path.basename(resolution.connectorPath, path.extname(resolution.connectorPath))}`);
|
|
837
|
+
if (sourceDetails?.authMode === "legacy" &&
|
|
838
|
+
!options.noInput &&
|
|
839
|
+
process.platform === "linux" &&
|
|
840
|
+
!process.env.DISPLAY &&
|
|
841
|
+
!process.env.WAYLAND_DISPLAY) {
|
|
842
|
+
const message = "This source needs a manual browser step, but no local display server is available.";
|
|
843
|
+
await updateSourceState(resolution.source, {
|
|
844
|
+
connectorInstalled: true,
|
|
845
|
+
sessionPresent: fs.existsSync(profilePath),
|
|
846
|
+
lastRunAt: new Date().toISOString(),
|
|
847
|
+
lastRunOutcome: CliOutcomeStatus.LEGACY_AUTH,
|
|
848
|
+
dataState: "none",
|
|
849
|
+
lastError: message,
|
|
850
|
+
lastResultPath: null,
|
|
851
|
+
lastLogPath: fetchLogPath ?? null,
|
|
852
|
+
});
|
|
853
|
+
renderer?.fail(`${displayName} requires a browser window, but no display is available.`);
|
|
854
|
+
renderer?.detail("Run this command in a desktop terminal.");
|
|
855
|
+
emit.event({
|
|
856
|
+
type: "outcome",
|
|
857
|
+
status: CliOutcomeStatus.LEGACY_AUTH,
|
|
858
|
+
source: resolution.source,
|
|
859
|
+
reason: "display_server_unavailable",
|
|
860
|
+
});
|
|
861
|
+
return 1;
|
|
862
|
+
}
|
|
863
|
+
await updateSourceState(resolution.source, {
|
|
864
|
+
connectorInstalled: true,
|
|
865
|
+
sessionPresent: fs.existsSync(profilePath),
|
|
866
|
+
lastError: null,
|
|
867
|
+
lastLogPath: fetchLogPath ?? null,
|
|
868
|
+
});
|
|
869
|
+
// --- Phase 4-5: Authentication + Collection ---
|
|
870
|
+
let finalStatus = CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR;
|
|
871
|
+
let finalDataState = "none";
|
|
872
|
+
let ingestFailureMessage = null;
|
|
873
|
+
let resultPath = getSourceResultPath(source);
|
|
874
|
+
let collectedResult = false;
|
|
875
|
+
let blockedByRequiredInput = false;
|
|
876
|
+
let ingestScopeResults;
|
|
877
|
+
// In IPC mode (--ipc), don’t provide an interactive callback.
|
|
878
|
+
// The runtime will write a pending-input file and poll for the
|
|
879
|
+
// response, letting an external agent handle credential collection.
|
|
880
|
+
const interactiveCallback = options.ipc
|
|
881
|
+
? undefined
|
|
882
|
+
: async (needInput) => {
|
|
883
|
+
renderer?.pauseForPrompt();
|
|
884
|
+
// Show connector’s prompt message
|
|
885
|
+
if (renderer) {
|
|
886
|
+
const promptMessage = needInput.message ?? `${displayName} needs your login.`;
|
|
887
|
+
process.stderr.write(`\n${promptMessage}\n\n`);
|
|
888
|
+
}
|
|
889
|
+
const values = {};
|
|
890
|
+
try {
|
|
891
|
+
for (const field of needInput.fields) {
|
|
892
|
+
const isPasswordField = field.toLowerCase().includes("password");
|
|
893
|
+
if (isPasswordField) {
|
|
894
|
+
values[field] = await password({
|
|
895
|
+
message: humanizeField(field),
|
|
896
|
+
...vanaPromptTheme,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
values[field] = await input({
|
|
901
|
+
message: humanizeField(field),
|
|
902
|
+
...vanaPromptTheme,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
if (isPromptCancelled(error)) {
|
|
909
|
+
throw new Error("__vana_prompt_cancelled__");
|
|
910
|
+
}
|
|
911
|
+
throw error;
|
|
912
|
+
}
|
|
913
|
+
if (renderer) {
|
|
914
|
+
process.stderr.write("\n");
|
|
915
|
+
}
|
|
916
|
+
renderer?.resumeAfterPrompt();
|
|
917
|
+
return values;
|
|
918
|
+
};
|
|
919
|
+
for await (const event of runtime.runConnector({
|
|
920
|
+
connectorPath: resolution.connectorPath,
|
|
921
|
+
source: resolution.source,
|
|
922
|
+
noInput: options.ipc ? false : options.noInput,
|
|
923
|
+
onNeedInput: interactiveCallback,
|
|
924
|
+
})) {
|
|
925
|
+
emit.event(event);
|
|
926
|
+
if (event.logPath) {
|
|
927
|
+
runLogPath = event.logPath;
|
|
928
|
+
}
|
|
929
|
+
if (pendingExitCode !== null && event.type !== "collection-complete") {
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (event.type === "needs-input") {
|
|
933
|
+
if (options.noInput && !options.ipc) {
|
|
934
|
+
blockedByRequiredInput = true;
|
|
935
|
+
}
|
|
936
|
+
await updateSourceState(resolution.source, {
|
|
937
|
+
lastRunAt: new Date().toISOString(),
|
|
938
|
+
lastRunOutcome: CliOutcomeStatus.NEEDS_INPUT,
|
|
939
|
+
lastError: event.message ?? "Input required.",
|
|
940
|
+
lastLogPath: event.logPath,
|
|
941
|
+
connectionHealth: "needs_reauth",
|
|
942
|
+
connectionHealthChangedAt: new Date().toISOString(),
|
|
943
|
+
connectionHealthReason: `needs-input: ${event.message ?? "Input required."}`,
|
|
944
|
+
connectionHealthRetryable: false,
|
|
945
|
+
});
|
|
946
|
+
emit.event({
|
|
947
|
+
type: "outcome",
|
|
948
|
+
status: CliOutcomeStatus.NEEDS_INPUT,
|
|
949
|
+
source: resolution.source,
|
|
950
|
+
});
|
|
951
|
+
renderer?.fail(`${displayName} needs credentials. Run without --no-input to authenticate.`);
|
|
952
|
+
pendingExitCode = 1;
|
|
953
|
+
}
|
|
954
|
+
if (event.type === "progress-update") {
|
|
955
|
+
// Drive the renderer with scope information from the event
|
|
956
|
+
const scopeName = extractScopeName(event);
|
|
957
|
+
if (scopeName && renderer) {
|
|
958
|
+
const isComplete = typeof event.message === "string" &&
|
|
959
|
+
/^complete\b/i.test(event.message.trim());
|
|
960
|
+
if (isComplete) {
|
|
961
|
+
const detail = formatScopeDetail(event);
|
|
962
|
+
renderer.scopeDone(scopeName, detail);
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
renderer.scopeActive(scopeName);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
if (event.type === "status-update") {
|
|
971
|
+
// Status updates are silent in the new design
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
if (event.type === "runtime-error") {
|
|
975
|
+
await updateSourceState(resolution.source, {
|
|
976
|
+
lastRunAt: new Date().toISOString(),
|
|
977
|
+
lastRunOutcome: CliOutcomeStatus.RUNTIME_ERROR,
|
|
978
|
+
lastError: event.message ?? "Connector run failed.",
|
|
979
|
+
lastLogPath: event.logPath,
|
|
980
|
+
connectionHealth: "error",
|
|
981
|
+
connectionHealthChangedAt: new Date().toISOString(),
|
|
982
|
+
connectionHealthReason: `runtime-error: ${event.message ?? "Connector run failed."}`,
|
|
983
|
+
connectionHealthRetryable: /timeout|ECONNREFUSED|ENOTFOUND|rate.?limit|50[234]|socket hang up/i.test(event.message ?? ""),
|
|
984
|
+
});
|
|
985
|
+
renderer?.fail(`Problem connecting ${displayName}.`);
|
|
986
|
+
renderer?.detail(event.message ?? "Connector run failed.");
|
|
987
|
+
renderer?.detail(`Retry: vana connect ${source}`);
|
|
988
|
+
emit.event({
|
|
989
|
+
type: "outcome",
|
|
990
|
+
status: CliOutcomeStatus.RUNTIME_ERROR,
|
|
991
|
+
source: resolution.source,
|
|
992
|
+
});
|
|
993
|
+
pendingExitCode = 1;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (event.type === "headed-required") {
|
|
997
|
+
// Silent — the browser opens automatically
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (event.type === "legacy-auth") {
|
|
1001
|
+
await updateSourceState(resolution.source, {
|
|
1002
|
+
lastRunAt: new Date().toISOString(),
|
|
1003
|
+
lastRunOutcome: CliOutcomeStatus.LEGACY_AUTH,
|
|
1004
|
+
lastError: event.message ?? "Legacy authentication is required.",
|
|
1005
|
+
dataState: "none",
|
|
1006
|
+
lastResultPath: null,
|
|
1007
|
+
lastLogPath: event.logPath,
|
|
1008
|
+
connectionHealth: "needs_reauth",
|
|
1009
|
+
connectionHealthChangedAt: new Date().toISOString(),
|
|
1010
|
+
connectionHealthReason: `legacy-auth: ${event.message ?? "Legacy authentication is required."}`,
|
|
1011
|
+
connectionHealthRetryable: false,
|
|
1012
|
+
});
|
|
1013
|
+
renderer?.fail(`Manual step required for ${displayName}.`);
|
|
1014
|
+
renderer?.detail(`Complete the browser step locally, then rerun vana connect ${source}.`);
|
|
1015
|
+
emit.event({
|
|
1016
|
+
type: "outcome",
|
|
1017
|
+
status: CliOutcomeStatus.LEGACY_AUTH,
|
|
1018
|
+
source: resolution.source,
|
|
1019
|
+
});
|
|
1020
|
+
pendingExitCode = 1;
|
|
1021
|
+
}
|
|
1022
|
+
if (event.type === "collection-complete" && event.resultPath) {
|
|
1023
|
+
if (blockedByRequiredInput) {
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
// Check if the result is actually an error object
|
|
1027
|
+
try {
|
|
1028
|
+
const raw = await fsp.readFile(event.resultPath, "utf8");
|
|
1029
|
+
const parsed = JSON.parse(raw);
|
|
1030
|
+
if (parsed &&
|
|
1031
|
+
typeof parsed === "object" &&
|
|
1032
|
+
"error" in parsed &&
|
|
1033
|
+
Object.keys(parsed).length <= 2) {
|
|
1034
|
+
// Connector returned an error, not real data
|
|
1035
|
+
const errorMsg = typeof parsed.error === "string"
|
|
1036
|
+
? parsed.error
|
|
1037
|
+
: "Collection returned an error";
|
|
1038
|
+
await updateSourceState(source, {
|
|
1039
|
+
lastRunAt: new Date().toISOString(),
|
|
1040
|
+
lastRunOutcome: CliOutcomeStatus.RUNTIME_ERROR,
|
|
1041
|
+
connectionHealth: "error",
|
|
1042
|
+
connectionHealthChangedAt: new Date().toISOString(),
|
|
1043
|
+
connectionHealthReason: `error-result: ${errorMsg}`,
|
|
1044
|
+
connectionHealthRetryable: false,
|
|
1045
|
+
lastError: errorMsg,
|
|
1046
|
+
lastLogPath: runLogPath ?? fetchLogPath,
|
|
1047
|
+
});
|
|
1048
|
+
renderer?.fail(`Problem connecting ${displayName}.`);
|
|
1049
|
+
renderer?.detail(typeof parsed.error === "string"
|
|
1050
|
+
? parsed.error
|
|
1051
|
+
: "The connector returned an error instead of data.");
|
|
1052
|
+
emit.event({
|
|
1053
|
+
type: "outcome",
|
|
1054
|
+
status: CliOutcomeStatus.RUNTIME_ERROR,
|
|
1055
|
+
source,
|
|
1056
|
+
});
|
|
1057
|
+
pendingExitCode = 1;
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
catch (parseError) {
|
|
1062
|
+
const msg = parseError instanceof Error ? parseError.message : "Unknown error";
|
|
1063
|
+
await updateSourceState(source, {
|
|
1064
|
+
lastError: `Failed to parse result file (${event.resultPath}): ${msg}`,
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
collectedResult = true;
|
|
1068
|
+
resultPath = event.resultPath;
|
|
1069
|
+
const ingestEvents = await ingestResult(resolution.source, resultPath, target);
|
|
1070
|
+
for (const ingestEvent of ingestEvents) {
|
|
1071
|
+
emit.event(ingestEvent);
|
|
1072
|
+
}
|
|
1073
|
+
const scopeResults = ingestEvents.find((e) => e.type === "ingest-complete" ||
|
|
1074
|
+
e.type === "ingest-partial" ||
|
|
1075
|
+
e.type === "ingest-failed")?.scopeResults;
|
|
1076
|
+
const ingestCompleted = ingestEvents.some((ingestEvent) => ingestEvent.type === "ingest-complete");
|
|
1077
|
+
const ingestPartial = ingestEvents.some((ingestEvent) => ingestEvent.type === "ingest-partial");
|
|
1078
|
+
const ingestFailedEvent = ingestEvents.find((ingestEvent) => ingestEvent.type === "ingest-failed");
|
|
1079
|
+
const ingestSkippedUnavailable = ingestEvents.some((ingestEvent) => ingestEvent.type === "ingest-skipped" &&
|
|
1080
|
+
ingestEvent.reason === "personal_server_unavailable");
|
|
1081
|
+
if (ingestCompleted) {
|
|
1082
|
+
finalStatus = CliOutcomeStatus.CONNECTED_AND_INGESTED;
|
|
1083
|
+
finalDataState = "ingested_personal_server";
|
|
1084
|
+
}
|
|
1085
|
+
else if (ingestPartial) {
|
|
1086
|
+
finalStatus = CliOutcomeStatus.CONNECTED_AND_INGESTED;
|
|
1087
|
+
finalDataState = "ingested_personal_server";
|
|
1088
|
+
}
|
|
1089
|
+
else if (ingestFailedEvent?.type === "ingest-failed") {
|
|
1090
|
+
finalStatus = CliOutcomeStatus.INGEST_FAILED;
|
|
1091
|
+
finalDataState = "ingest_failed";
|
|
1092
|
+
ingestFailureMessage =
|
|
1093
|
+
ingestFailedEvent.message ?? "Personal Server sync failed.";
|
|
1094
|
+
}
|
|
1095
|
+
else if (ingestSkippedUnavailable) {
|
|
1096
|
+
finalStatus = CliOutcomeStatus.CONNECTED_LOCAL_ONLY;
|
|
1097
|
+
finalDataState = "ingest_unavailable";
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
finalStatus = CliOutcomeStatus.CONNECTED_LOCAL_ONLY;
|
|
1101
|
+
finalDataState = "collected_local";
|
|
1102
|
+
}
|
|
1103
|
+
// Store per-scope results in state
|
|
1104
|
+
ingestScopeResults = scopeResults?.map((r) => ({
|
|
1105
|
+
scope: r.scope,
|
|
1106
|
+
status: r.status,
|
|
1107
|
+
syncedAt: r.status === "stored" ? new Date().toISOString() : undefined,
|
|
1108
|
+
error: r.error,
|
|
1109
|
+
}));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (pendingExitCode !== null && !collectedResult) {
|
|
1113
|
+
return pendingExitCode;
|
|
1114
|
+
}
|
|
1115
|
+
if (!collectedResult) {
|
|
1116
|
+
await updateSourceState(resolution.source, {
|
|
1117
|
+
connectorInstalled: true,
|
|
1118
|
+
sessionPresent: fs.existsSync(profilePath),
|
|
1119
|
+
lastRunAt: new Date().toISOString(),
|
|
1120
|
+
lastRunOutcome: CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR,
|
|
1121
|
+
dataState: "none",
|
|
1122
|
+
lastError: "Connector run ended without a result.",
|
|
1123
|
+
lastResultPath: null,
|
|
1124
|
+
lastLogPath: runLogPath ?? fetchLogPath ?? null,
|
|
1125
|
+
});
|
|
1126
|
+
renderer?.fail(`Problem connecting ${displayName}.`);
|
|
1127
|
+
renderer?.detail("Connector run ended without a result.");
|
|
1128
|
+
emit.event({
|
|
1129
|
+
type: "outcome",
|
|
1130
|
+
status: CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR,
|
|
1131
|
+
source: resolution.source,
|
|
1132
|
+
reason: "Connector run ended without a result.",
|
|
1133
|
+
});
|
|
1134
|
+
return 1;
|
|
1135
|
+
}
|
|
1136
|
+
await updateSourceState(resolution.source, {
|
|
1137
|
+
connectorInstalled: true,
|
|
1138
|
+
connectorVersion: fetched.version,
|
|
1139
|
+
exportFrequency: fetched.exportFrequency,
|
|
1140
|
+
sessionPresent: true,
|
|
1141
|
+
lastRunAt: new Date().toISOString(),
|
|
1142
|
+
lastCollectedAt: new Date().toISOString(),
|
|
1143
|
+
lastRunOutcome: finalStatus,
|
|
1144
|
+
dataState: finalDataState,
|
|
1145
|
+
lastError: ingestFailureMessage,
|
|
1146
|
+
lastResultPath: resultPath,
|
|
1147
|
+
lastLogPath: runLogPath ?? fetchLogPath ?? setupLogPath ?? null,
|
|
1148
|
+
connectionHealth: pendingExitCode !== null ? undefined : "healthy",
|
|
1149
|
+
connectionHealthChangedAt: pendingExitCode !== null ? undefined : new Date().toISOString(),
|
|
1150
|
+
connectionHealthReason: pendingExitCode !== null ? undefined : "collection-complete",
|
|
1151
|
+
connectionHealthRetryable: undefined,
|
|
1152
|
+
ingestScopes: ingestScopeResults,
|
|
1153
|
+
});
|
|
1154
|
+
// Build scope-aware success summary
|
|
1155
|
+
const storedCount = ingestScopeResults?.filter((r) => r.status === "stored").length ?? 0;
|
|
1156
|
+
const failedCount = ingestScopeResults?.filter((r) => r.status === "failed").length ?? 0;
|
|
1157
|
+
const totalScopes = ingestScopeResults?.length ?? 0;
|
|
1158
|
+
let successSummary;
|
|
1159
|
+
if (finalStatus === CliOutcomeStatus.CONNECTED_AND_INGESTED &&
|
|
1160
|
+
totalScopes > 0) {
|
|
1161
|
+
if (failedCount === 0) {
|
|
1162
|
+
successSummary = `Collected your ${displayName} data and synced it to your Personal Server.`;
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
successSummary = `Collected your ${displayName} data. ${storedCount}/${totalScopes} scopes synced, ${failedCount} failed.`;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
else if (finalStatus === CliOutcomeStatus.CONNECTED_AND_INGESTED) {
|
|
1169
|
+
successSummary = `Collected your ${displayName} data and synced it to your Personal Server.`;
|
|
1170
|
+
}
|
|
1171
|
+
else if (finalDataState === "ingest_unavailable") {
|
|
1172
|
+
successSummary = `Collected your ${displayName} data. Personal Server sync is pending.`;
|
|
1173
|
+
}
|
|
1174
|
+
else {
|
|
1175
|
+
successSummary = `Collected your ${displayName} data and saved it locally.`;
|
|
1176
|
+
}
|
|
1177
|
+
// Auto-schedule collection if no schedule exists (non-blocking)
|
|
1178
|
+
await maybeAutoSchedule(emit, options).catch(() => { });
|
|
1179
|
+
// --- Phase 7: Success summary ---
|
|
1180
|
+
renderer?.success(`Connected ${displayName}.`);
|
|
1181
|
+
renderer?.detail(successSummary);
|
|
1182
|
+
// Partial sync guidance
|
|
1183
|
+
if (failedCount > 0 && storedCount > 0) {
|
|
1184
|
+
renderer?.detail(`Retry: vana server sync`);
|
|
1185
|
+
}
|
|
1186
|
+
else if (finalDataState === "ingest_unavailable") {
|
|
1187
|
+
renderer?.detail(`Pending sync will retry during scheduled collection.`);
|
|
1188
|
+
renderer?.detail(`Retry now: vana server sync`);
|
|
1189
|
+
}
|
|
1190
|
+
// Journey-aware next step
|
|
1191
|
+
const state = await readCliState();
|
|
1192
|
+
const connectedSourceCount = Object.values(state.sources ?? {}).filter((s) => hasCollectedData(s?.dataState)).length;
|
|
1193
|
+
renderer?.detail("");
|
|
1194
|
+
if (connectedSourceCount > 1) {
|
|
1195
|
+
renderer?.next("vana sources");
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
renderer?.next(`vana data show ${source}`);
|
|
1199
|
+
}
|
|
1200
|
+
// Suggest skills if not yet installed
|
|
1201
|
+
const installedSkills = await readInstalledSkills();
|
|
1202
|
+
if (installedSkills.length === 0) {
|
|
1203
|
+
renderer?.detail("Your coding agent can use this data — run `vana skills` to see how.");
|
|
1204
|
+
}
|
|
1205
|
+
renderer?.bell();
|
|
1206
|
+
// Offer skill install on first successful connect (ask once)
|
|
1207
|
+
if (!state.config?.skillsPromptCompleted &&
|
|
1208
|
+
!options.json &&
|
|
1209
|
+
!options.noInput &&
|
|
1210
|
+
process.stdin.isTTY) {
|
|
1211
|
+
await maybePromptSkillInstall(emit);
|
|
1212
|
+
}
|
|
1213
|
+
// Emit for --json consumers (unchanged)
|
|
1214
|
+
emit.event({
|
|
1215
|
+
type: "outcome",
|
|
1216
|
+
status: finalStatus,
|
|
1217
|
+
source: resolution.source,
|
|
1218
|
+
resultPath,
|
|
1219
|
+
});
|
|
1220
|
+
if (pendingExitCode !== null) {
|
|
1221
|
+
return pendingExitCode;
|
|
1222
|
+
}
|
|
1223
|
+
return 0;
|
|
1224
|
+
}
|
|
1225
|
+
catch (error) {
|
|
1226
|
+
if (error instanceof Error &&
|
|
1227
|
+
error.message === "__vana_prompt_cancelled__") {
|
|
1228
|
+
await updateSourceState(source, {
|
|
1229
|
+
lastRunAt: new Date().toISOString(),
|
|
1230
|
+
lastRunOutcome: CliOutcomeStatus.NEEDS_INPUT,
|
|
1231
|
+
lastError: "Cancelled before input was completed.",
|
|
1232
|
+
lastLogPath: runLogPath ?? null,
|
|
1233
|
+
});
|
|
1234
|
+
renderer?.fail("Cancelled.");
|
|
1235
|
+
emit.event({
|
|
1236
|
+
type: "outcome",
|
|
1237
|
+
status: CliOutcomeStatus.NEEDS_INPUT,
|
|
1238
|
+
source,
|
|
1239
|
+
reason: "prompt_cancelled",
|
|
1240
|
+
});
|
|
1241
|
+
return 1;
|
|
1242
|
+
}
|
|
1243
|
+
const message = error instanceof Error ? error.message : "Unexpected error.";
|
|
1244
|
+
renderer?.fail(`Problem connecting ${displayName}.`);
|
|
1245
|
+
renderer?.detail(message);
|
|
1246
|
+
renderer?.detail(`Retry: vana connect ${source}`);
|
|
1247
|
+
emit.event({
|
|
1248
|
+
type: "outcome",
|
|
1249
|
+
status: CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR,
|
|
1250
|
+
source,
|
|
1251
|
+
reason: message,
|
|
1252
|
+
});
|
|
1253
|
+
return 1;
|
|
1254
|
+
}
|
|
1255
|
+
finally {
|
|
1256
|
+
renderer?.cleanup();
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
async function runConnectEntry(options) {
|
|
1260
|
+
const emit = createEmitter(options);
|
|
1261
|
+
const sources = await loadRegistrySources();
|
|
1262
|
+
const state = await readCliState();
|
|
1263
|
+
const sourceMetadata = createSourceMetadataMap(sources);
|
|
1264
|
+
const statuses = await gatherSourceStatuses(state.sources, sourceMetadata);
|
|
1265
|
+
const statusMap = new Map(statuses.map((source) => [source.source, source]));
|
|
1266
|
+
const enrichedSources = sources.map((source) => {
|
|
1267
|
+
const status = statusMap.get(source.id);
|
|
1268
|
+
return {
|
|
1269
|
+
...source,
|
|
1270
|
+
dataState: status?.dataState,
|
|
1271
|
+
lastRunOutcome: status?.lastRunOutcome ?? null,
|
|
1272
|
+
sessionPresent: status?.sessionPresent ?? false,
|
|
1273
|
+
};
|
|
1274
|
+
});
|
|
1275
|
+
const suggestedSource = enrichedSources.find((source) => source.authMode !== "legacy" && !hasCollectedData(source.dataState)) ??
|
|
1276
|
+
enrichedSources.find((source) => source.authMode !== "legacy") ??
|
|
1277
|
+
enrichedSources[0];
|
|
1278
|
+
const missingSourceMessage = formatMissingConnectSourceMessage(suggestedSource);
|
|
1279
|
+
if (options.json) {
|
|
1280
|
+
process.stdout.write(`${JSON.stringify({
|
|
1281
|
+
error: "source_required",
|
|
1282
|
+
message: missingSourceMessage,
|
|
1283
|
+
suggestedSource: suggestedSource
|
|
1284
|
+
? {
|
|
1285
|
+
id: suggestedSource.id,
|
|
1286
|
+
name: suggestedSource.name,
|
|
1287
|
+
authMode: suggestedSource.authMode,
|
|
1288
|
+
}
|
|
1289
|
+
: null,
|
|
1290
|
+
})}\n`);
|
|
1291
|
+
return 1;
|
|
1292
|
+
}
|
|
1293
|
+
if (options.noInput) {
|
|
1294
|
+
emit.info(missingSourceMessage);
|
|
1295
|
+
return 1;
|
|
1296
|
+
}
|
|
1297
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1298
|
+
emit.info(missingSourceMessage);
|
|
1299
|
+
return 1;
|
|
1300
|
+
}
|
|
1301
|
+
if (enrichedSources.length === 0) {
|
|
1302
|
+
emit.info("No sources are available right now.");
|
|
1303
|
+
emit.info("Run `vana sources` to verify the local connector registry.");
|
|
1304
|
+
return 1;
|
|
1305
|
+
}
|
|
1306
|
+
// Build inquirer-compatible choices from enriched sources
|
|
1307
|
+
const choices = enrichedSources.map((item) => {
|
|
1308
|
+
const connected = hasCollectedData(item.dataState);
|
|
1309
|
+
const hint = connected
|
|
1310
|
+
? "connected"
|
|
1311
|
+
: item.authMode === "legacy"
|
|
1312
|
+
? "browser login"
|
|
1313
|
+
: undefined;
|
|
1314
|
+
return {
|
|
1315
|
+
value: item.id,
|
|
1316
|
+
name: item.name,
|
|
1317
|
+
description: hint,
|
|
1318
|
+
};
|
|
1319
|
+
});
|
|
1320
|
+
try {
|
|
1321
|
+
const source = await searchSelect({
|
|
1322
|
+
message: "Choose a source to connect.",
|
|
1323
|
+
choices,
|
|
1324
|
+
...vanaPromptTheme,
|
|
1325
|
+
});
|
|
1326
|
+
return runConnect(source, options);
|
|
1327
|
+
}
|
|
1328
|
+
catch (error) {
|
|
1329
|
+
if (isPromptCancelled(error)) {
|
|
1330
|
+
emit.info("Cancelled.");
|
|
1331
|
+
return 1;
|
|
1332
|
+
}
|
|
1333
|
+
throw error;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
async function runList(options) {
|
|
1337
|
+
const result = await querySources();
|
|
1338
|
+
const { sources: enrichedSources, recommendedSource } = result;
|
|
1339
|
+
if (options.json) {
|
|
1340
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1341
|
+
return 0;
|
|
1342
|
+
}
|
|
1343
|
+
const emit = createEmitter(options);
|
|
1344
|
+
emit.title("Available sources");
|
|
1345
|
+
emit.blank();
|
|
1346
|
+
if (enrichedSources.length === 0) {
|
|
1347
|
+
emit.info("No sources are available right now.");
|
|
1348
|
+
}
|
|
1349
|
+
else {
|
|
1350
|
+
const connectedSources = enrichedSources.filter((source) => hasCollectedData(source.dataState));
|
|
1351
|
+
const unconnectedSources = enrichedSources.filter((source) => !hasCollectedData(source.dataState));
|
|
1352
|
+
// Connected sources are always shown expanded
|
|
1353
|
+
if (connectedSources.length > 0) {
|
|
1354
|
+
emit.section("Connected");
|
|
1355
|
+
for (const source of connectedSources) {
|
|
1356
|
+
const badges = [];
|
|
1357
|
+
if (source.dataState === "ingested_personal_server") {
|
|
1358
|
+
badges.push({ text: "synced", tone: "success" });
|
|
1359
|
+
}
|
|
1360
|
+
else if (source.dataState === "ingest_failed") {
|
|
1361
|
+
badges.push({ text: "sync failed", tone: "warning" });
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
badges.push({ text: "local", tone: "muted" });
|
|
1365
|
+
}
|
|
1366
|
+
emit.sourceTitle(source.name, badges);
|
|
1367
|
+
emit.detail(`Inspect with ${emit.code(`vana data show ${source.id}`)}.`);
|
|
1368
|
+
}
|
|
1369
|
+
emit.blank();
|
|
1370
|
+
emit.section("Available");
|
|
1371
|
+
}
|
|
1372
|
+
for (const source of unconnectedSources) {
|
|
1373
|
+
const badges = [];
|
|
1374
|
+
if (recommendedSource?.id === source.id &&
|
|
1375
|
+
recommendedSource.authMode !== "legacy") {
|
|
1376
|
+
badges.push({ text: "recommended", tone: "accent" });
|
|
1377
|
+
}
|
|
1378
|
+
emit.sourceTitle(source.name, badges);
|
|
1379
|
+
if (source.description) {
|
|
1380
|
+
emit.detail(cleanDescription(source.description));
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
if (recommendedSource) {
|
|
1384
|
+
emit.blank();
|
|
1385
|
+
emit.next(`vana connect ${recommendedSource.id}`);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return 0;
|
|
1389
|
+
}
|
|
1390
|
+
async function runStatus(options) {
|
|
1391
|
+
const { status, nextSteps } = await queryStatus();
|
|
1392
|
+
const state = await readCliState();
|
|
1393
|
+
// Build per-source health map from stored state
|
|
1394
|
+
const sourceHealthMap = {};
|
|
1395
|
+
for (const [sourceId, stored] of Object.entries(state.sources)) {
|
|
1396
|
+
if (stored) {
|
|
1397
|
+
sourceHealthMap[sourceId] = {
|
|
1398
|
+
connectionHealth: stored.connectionHealth,
|
|
1399
|
+
connectionHealthChangedAt: stored.connectionHealthChangedAt,
|
|
1400
|
+
connectionHealthReason: stored.connectionHealthReason,
|
|
1401
|
+
connectionHealthRetryable: stored.connectionHealthRetryable,
|
|
1402
|
+
lastCollectedAt: stored.lastCollectedAt,
|
|
1403
|
+
lastLogPath: stored.lastLogPath,
|
|
1404
|
+
lastError: stored.lastError,
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (options.json) {
|
|
1409
|
+
const jsonAuthCreds = loadCredentials();
|
|
1410
|
+
const compactJson = {
|
|
1411
|
+
runtime: status.runtime,
|
|
1412
|
+
personalServer: status.personalServer,
|
|
1413
|
+
personalServerUrl: status.personalServerUrl,
|
|
1414
|
+
pendingSyncCount: status.pendingSyncCount ?? 0,
|
|
1415
|
+
auth: jsonAuthCreds
|
|
1416
|
+
? {
|
|
1417
|
+
authenticated: !isExpired(jsonAuthCreds),
|
|
1418
|
+
address: jsonAuthCreds.account.address,
|
|
1419
|
+
expires_at: jsonAuthCreds.account.expires_at,
|
|
1420
|
+
}
|
|
1421
|
+
: { authenticated: false },
|
|
1422
|
+
sources: {
|
|
1423
|
+
connected: status.summary?.connectedCount ?? 0,
|
|
1424
|
+
needsAttention: status.summary?.needsAttentionCount ?? 0,
|
|
1425
|
+
},
|
|
1426
|
+
sourceHealth: sourceHealthMap,
|
|
1427
|
+
next: nextSteps[0] ?? null,
|
|
1428
|
+
};
|
|
1429
|
+
process.stdout.write(`${JSON.stringify(compactJson)}\n`);
|
|
1430
|
+
return 0;
|
|
1431
|
+
}
|
|
1432
|
+
const emit = createEmitter(options);
|
|
1433
|
+
const registrySources = await loadRegistrySources();
|
|
1434
|
+
const sourceLabels = createSourceLabelMap(registrySources);
|
|
1435
|
+
emit.title("Vana Connect");
|
|
1436
|
+
emit.blank();
|
|
1437
|
+
emit.keyValue("Runtime", status.runtime, toneForRuntime(status.runtime));
|
|
1438
|
+
if (status.personalServer === "available") {
|
|
1439
|
+
emit.keyValue("Personal Server", status.personalServerUrl ?? "connected", "success");
|
|
1440
|
+
}
|
|
1441
|
+
else {
|
|
1442
|
+
emit.keyValue("Personal Server", "not connected", "warning");
|
|
1443
|
+
}
|
|
1444
|
+
// Auth state
|
|
1445
|
+
const authCreds = loadCredentials();
|
|
1446
|
+
if (authCreds && !isExpired(authCreds)) {
|
|
1447
|
+
emit.keyValue("Account", authCreds.account.address, "success");
|
|
1448
|
+
emit.keyValue("Auth", `Authenticated (expires in ${formatExpiresIn(authCreds.account.expires_at)})`, "success");
|
|
1449
|
+
}
|
|
1450
|
+
else {
|
|
1451
|
+
emit.keyValue("Account", "Not logged in", "muted");
|
|
1452
|
+
emit.keyValue("Auth", "Run `vana login` to authenticate", "muted");
|
|
1453
|
+
}
|
|
1454
|
+
const trackedSources = status.sources.filter(shouldDisplaySourceInStatus);
|
|
1455
|
+
const attentionSources = trackedSources
|
|
1456
|
+
.filter(isSourceAttention)
|
|
1457
|
+
.sort(compareAttentionPriority);
|
|
1458
|
+
const healthySources = trackedSources
|
|
1459
|
+
.filter((source) => !isSourceAttention(source))
|
|
1460
|
+
.sort(compareSourceStatusOrder);
|
|
1461
|
+
const sourceParts = [
|
|
1462
|
+
healthySources.length > 0
|
|
1463
|
+
? `${healthySources.length} healthy`
|
|
1464
|
+
: trackedSources.length > 0
|
|
1465
|
+
? "none healthy"
|
|
1466
|
+
: "none connected",
|
|
1467
|
+
...(attentionSources.length > 0
|
|
1468
|
+
? [
|
|
1469
|
+
`${attentionSources.length} need${attentionSources.length === 1 ? "s" : ""} attention`,
|
|
1470
|
+
]
|
|
1471
|
+
: []),
|
|
1472
|
+
];
|
|
1473
|
+
emit.keyValue("Sources", sourceParts.join(", "), attentionSources.length > 0 && healthySources.length > 0
|
|
1474
|
+
? "warning"
|
|
1475
|
+
: healthySources.length > 0
|
|
1476
|
+
? "success"
|
|
1477
|
+
: "muted");
|
|
1478
|
+
if ((status.pendingSyncCount ?? 0) > 0) {
|
|
1479
|
+
emit.keyValue("Pending sync", `${status.pendingSyncCount} dataset(s)`, "warning");
|
|
1480
|
+
}
|
|
1481
|
+
if (attentionSources.length > 0) {
|
|
1482
|
+
emit.blank();
|
|
1483
|
+
emit.section(formatCountLabel("Needs attention", attentionSources.length));
|
|
1484
|
+
for (const source of attentionSources) {
|
|
1485
|
+
emitHumanStatusSource(emit, source, sourceLabels);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
if (healthySources.length > 0) {
|
|
1489
|
+
emit.blank();
|
|
1490
|
+
emit.section(formatCountLabel("Healthy", healthySources.length));
|
|
1491
|
+
for (const source of healthySources) {
|
|
1492
|
+
emitHumanStatusSource(emit, source, sourceLabels);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
if (nextSteps.length > 0) {
|
|
1496
|
+
emit.blank();
|
|
1497
|
+
const command = extractCommand(nextSteps[0]);
|
|
1498
|
+
if (command) {
|
|
1499
|
+
emit.next(command);
|
|
1500
|
+
}
|
|
1501
|
+
else {
|
|
1502
|
+
emit.detail(`Next: ${nextSteps[0]}`);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return 0;
|
|
1506
|
+
}
|
|
1507
|
+
function shouldDisplaySourceInStatus(source) {
|
|
1508
|
+
return (source.installed ||
|
|
1509
|
+
Boolean(source.lastRunOutcome) ||
|
|
1510
|
+
source.dataState !== "none" ||
|
|
1511
|
+
Boolean(source.connectionHealth));
|
|
1512
|
+
}
|
|
1513
|
+
function emitHumanStatusSource(emit, source, sourceLabels) {
|
|
1514
|
+
const displayName = displaySource(source.source, sourceLabels);
|
|
1515
|
+
const presentation = getHumanStatusPresentation(source);
|
|
1516
|
+
const staleTag = source.isOverdue ? ` ${emit.badge("stale", "warning")}` : "";
|
|
1517
|
+
const collectedAgo = source.lastCollectedAt
|
|
1518
|
+
? `collected ${formatRelativeTime(source.lastCollectedAt)}`
|
|
1519
|
+
: "";
|
|
1520
|
+
emit.keyValue(` ${displayName}`, `${presentation.label}${staleTag}${collectedAgo ? ` ${collectedAgo}` : ""}`, presentation.tone);
|
|
1521
|
+
const detail = formatHumanStatusDetail(source);
|
|
1522
|
+
if (detail) {
|
|
1523
|
+
emit.detail(` \u21b3 ${detail}`);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
function getHumanStatusPresentation(source) {
|
|
1527
|
+
if (source.dataState === "ingest_failed") {
|
|
1528
|
+
return { label: "sync failed", tone: "warning" };
|
|
1529
|
+
}
|
|
1530
|
+
if (source.dataState === "ingest_unavailable") {
|
|
1531
|
+
return { label: "pending sync", tone: "warning" };
|
|
1532
|
+
}
|
|
1533
|
+
if (source.connectionHealth === "needs_reauth") {
|
|
1534
|
+
return { label: "needs login", tone: "warning" };
|
|
1535
|
+
}
|
|
1536
|
+
if (source.connectionHealth === "error") {
|
|
1537
|
+
return { label: "error", tone: "error" };
|
|
1538
|
+
}
|
|
1539
|
+
const presentation = getSourceStatusPresentation(source);
|
|
1540
|
+
if (presentation.label === "needs input") {
|
|
1541
|
+
return { label: "needs login", tone: presentation.tone };
|
|
1542
|
+
}
|
|
1543
|
+
return presentation;
|
|
1544
|
+
}
|
|
1545
|
+
function formatHumanStatusDetail(source) {
|
|
1546
|
+
if (source.dataState === "ingest_failed") {
|
|
1547
|
+
return formatSyncFailureSummary(source);
|
|
1548
|
+
}
|
|
1549
|
+
if (source.dataState === "ingest_unavailable") {
|
|
1550
|
+
return "Personal Server unavailable. Pending sync will retry during scheduled collection. Run `vana server sync`.";
|
|
1551
|
+
}
|
|
1552
|
+
if (source.connectionHealth === "needs_reauth" ||
|
|
1553
|
+
source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
|
|
1554
|
+
return `Authentication required. Run \`vana connect ${source.source}\`.`;
|
|
1555
|
+
}
|
|
1556
|
+
if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
|
|
1557
|
+
return `Manual auth step required. Run \`vana connect ${source.source}\`.`;
|
|
1558
|
+
}
|
|
1559
|
+
if (source.connectionHealth === "error") {
|
|
1560
|
+
const message = formatHealthMessage(source.connectionHealthReason);
|
|
1561
|
+
return `${message ?? "Collection failed."} Run \`vana connect ${source.source}\`.`;
|
|
1562
|
+
}
|
|
1563
|
+
if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
|
|
1564
|
+
return `${humanizeIssue(source.lastError ?? "Collection failed")}. Run \`vana connect ${source.source}\`.`;
|
|
1565
|
+
}
|
|
1566
|
+
if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
|
|
1567
|
+
return "No connector available. Run `vana sources`.";
|
|
1568
|
+
}
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
function formatSyncFailureSummary(source) {
|
|
1572
|
+
const failedScopes = source.ingestScopes?.filter((scope) => scope.status === "failed") ?? [];
|
|
1573
|
+
if (failedScopes.length === 0) {
|
|
1574
|
+
return `Personal Server sync failed. Run \`vana connect ${source.source}\`.`;
|
|
1575
|
+
}
|
|
1576
|
+
const groupedFailures = new Map();
|
|
1577
|
+
for (const failedScope of failedScopes) {
|
|
1578
|
+
const summary = humanizeIssue(failedScope.error ?? "Sync failed");
|
|
1579
|
+
groupedFailures.set(summary, (groupedFailures.get(summary) ?? 0) + 1);
|
|
1580
|
+
}
|
|
1581
|
+
const entries = Array.from(groupedFailures.entries());
|
|
1582
|
+
if (entries.length === 1) {
|
|
1583
|
+
const [summary, count] = entries[0];
|
|
1584
|
+
return `${summary}${count > 1 ? ` for ${count} scopes` : ""}. Run \`vana connect ${source.source}\`.`;
|
|
1585
|
+
}
|
|
1586
|
+
const summaryParts = entries.map(([summary, count]) => count > 1 ? `${summary} (${count})` : summary);
|
|
1587
|
+
return `${failedScopes.length} scopes failed to sync: ${summaryParts.join("; ")}. Run \`vana connect ${source.source}\`.`;
|
|
1588
|
+
}
|
|
1589
|
+
async function runDoctor(options) {
|
|
1590
|
+
const payload = await queryDoctor();
|
|
1591
|
+
if (options.json) {
|
|
1592
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
1593
|
+
return 0;
|
|
1594
|
+
}
|
|
1595
|
+
const sourceLabels = createSourceLabelMap(await loadRegistrySources());
|
|
1596
|
+
const { recentSources } = payload;
|
|
1597
|
+
const attentionSources = recentSources.filter((source) => rankSourceStatus(source) <= 4);
|
|
1598
|
+
const emit = createEmitter(options);
|
|
1599
|
+
emit.title("Vana Connect doctor");
|
|
1600
|
+
emit.section("Summary");
|
|
1601
|
+
emit.keyValue("CLI", payload.cliVersion, "muted");
|
|
1602
|
+
emit.keyValue("Channel", payload.channel, "muted");
|
|
1603
|
+
emit.keyValue("Install", formatInstallMethodLabel(payload.installMethod), "muted");
|
|
1604
|
+
emit.keyValue("Runtime", payload.runtime, toneForRuntime(payload.runtime));
|
|
1605
|
+
emit.keyValue("Personal Server", payload.personalServer, payload.personalServer === "available" ? "success" : "warning");
|
|
1606
|
+
emit.keyValue("Tracked sources", String(payload.summary.trackedSourceCount), "muted");
|
|
1607
|
+
emit.keyValue("Attention", String(payload.summary.attentionCount), payload.summary.attentionCount > 0 ? "warning" : "muted");
|
|
1608
|
+
emit.keyValue("Connected", String(payload.summary.connectedCount), payload.summary.connectedCount > 0 ? "success" : "muted");
|
|
1609
|
+
emit.blank();
|
|
1610
|
+
emit.section("Checks");
|
|
1611
|
+
for (const check of payload.checks) {
|
|
1612
|
+
const tone = check.status === "ok"
|
|
1613
|
+
? "success"
|
|
1614
|
+
: check.status === "warn"
|
|
1615
|
+
? "warning"
|
|
1616
|
+
: "error";
|
|
1617
|
+
emit.keyValue(check.label, check.detail, tone);
|
|
1618
|
+
}
|
|
1619
|
+
if (recentSources.length > 0) {
|
|
1620
|
+
emit.blank();
|
|
1621
|
+
emit.section(attentionSources.length > 0
|
|
1622
|
+
? "Needs attention"
|
|
1623
|
+
: "Recent source activity");
|
|
1624
|
+
for (const source of attentionSources.length > 0
|
|
1625
|
+
? attentionSources
|
|
1626
|
+
: recentSources) {
|
|
1627
|
+
const status = getSourceStatusPresentation(source);
|
|
1628
|
+
const badges = [];
|
|
1629
|
+
badges.push({ text: status.label, tone: status.tone });
|
|
1630
|
+
emit.sourceTitle(displaySource(source.source, sourceLabels), badges);
|
|
1631
|
+
const details = formatSourceStatusDetails(source);
|
|
1632
|
+
for (const detail of details) {
|
|
1633
|
+
if (detail.kind === "row") {
|
|
1634
|
+
emit.keyValue(detail.label, detail.value, detail.tone ?? "muted");
|
|
1635
|
+
}
|
|
1636
|
+
else {
|
|
1637
|
+
emit.detail(humanizeIssue(detail.message));
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
emit.blank();
|
|
1643
|
+
emit.section("Paths");
|
|
1644
|
+
emit.keyValue("Executable", formatDisplayPath(payload.paths.executable), "muted");
|
|
1645
|
+
if (payload.paths.appRoot) {
|
|
1646
|
+
emit.keyValue("App root", formatDisplayPath(payload.paths.appRoot), "muted");
|
|
1647
|
+
}
|
|
1648
|
+
emit.keyValue("Data home", formatDisplayPath(payload.paths.dataHome), "muted");
|
|
1649
|
+
emit.keyValue("State file", formatDisplayPath(payload.paths.stateFile), "muted");
|
|
1650
|
+
emit.keyValue("Connector cache", formatDisplayPath(payload.paths.connectorCache), "muted");
|
|
1651
|
+
emit.keyValue("Browser profiles", formatDisplayPath(payload.paths.browserProfiles), "muted");
|
|
1652
|
+
emit.keyValue("Logs", formatDisplayPath(payload.paths.logs), "muted");
|
|
1653
|
+
emit.blank();
|
|
1654
|
+
emit.section("Lifecycle");
|
|
1655
|
+
emit.keyValue("Upgrade", payload.lifecycle.upgrade, "muted");
|
|
1656
|
+
emit.keyValue("Uninstall", payload.lifecycle.uninstall, "muted");
|
|
1657
|
+
if (payload.nextSteps.length > 0) {
|
|
1658
|
+
emit.blank();
|
|
1659
|
+
const command = extractCommand(payload.nextSteps[0]);
|
|
1660
|
+
if (command) {
|
|
1661
|
+
emit.next(command);
|
|
1662
|
+
}
|
|
1663
|
+
else {
|
|
1664
|
+
emit.detail(`Next: ${payload.nextSteps[0]}`);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return 0;
|
|
1668
|
+
}
|
|
1669
|
+
async function runServerStatus(options) {
|
|
1670
|
+
const emit = createEmitter(options);
|
|
1671
|
+
const target = await detectPersonalServerTarget();
|
|
1672
|
+
const state = await readCliState();
|
|
1673
|
+
// Count scopes from state
|
|
1674
|
+
let totalScopeCount = 0;
|
|
1675
|
+
for (const stored of Object.values(state.sources)) {
|
|
1676
|
+
if (stored?.ingestScopes) {
|
|
1677
|
+
totalScopeCount += stored.ingestScopes.filter((s) => s.status === "stored").length;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
if (options.json) {
|
|
1681
|
+
process.stdout.write(`${JSON.stringify({
|
|
1682
|
+
state: target.state,
|
|
1683
|
+
url: target.url,
|
|
1684
|
+
source: target.source,
|
|
1685
|
+
health: target.health,
|
|
1686
|
+
scopeCount: totalScopeCount,
|
|
1687
|
+
})}\n`);
|
|
1688
|
+
return 0;
|
|
1689
|
+
}
|
|
1690
|
+
emit.title("Personal Server");
|
|
1691
|
+
emit.blank();
|
|
1692
|
+
if (target.url) {
|
|
1693
|
+
const urlSuffix = target.source === "scan"
|
|
1694
|
+
? "(auto-detected)"
|
|
1695
|
+
: target.source === "config"
|
|
1696
|
+
? "(saved)"
|
|
1697
|
+
: target.source === "auth"
|
|
1698
|
+
? "(from vana login)"
|
|
1699
|
+
: target.source === "env"
|
|
1700
|
+
? "(from VANA_PERSONAL_SERVER_URL)"
|
|
1701
|
+
: `(${target.source ?? "unknown"})`;
|
|
1702
|
+
emit.keyValue("URL", `${target.url} ${urlSuffix}`, "muted");
|
|
1703
|
+
}
|
|
1704
|
+
const stateLabel = target.state === "available" ? "healthy" : "Not connected";
|
|
1705
|
+
emit.keyValue("Status", stateLabel, target.state === "available" ? "success" : "warning");
|
|
1706
|
+
if (target.health) {
|
|
1707
|
+
emit.keyValue("Version", target.health.version, "muted");
|
|
1708
|
+
}
|
|
1709
|
+
if (totalScopeCount > 0) {
|
|
1710
|
+
emit.keyValue("Scopes", `${totalScopeCount} stored`, "muted");
|
|
1711
|
+
}
|
|
1712
|
+
if (target.source && !target.url) {
|
|
1713
|
+
const sourceLabel = {
|
|
1714
|
+
config: "Saved config",
|
|
1715
|
+
env: "VANA_PERSONAL_SERVER_URL",
|
|
1716
|
+
scan: "Localhost scan",
|
|
1717
|
+
};
|
|
1718
|
+
emit.keyValue("Resolved via", sourceLabel[target.source] ?? target.source, "muted");
|
|
1719
|
+
}
|
|
1720
|
+
if (target.health) {
|
|
1721
|
+
emit.keyValue("Uptime", formatUptime(target.health.uptime), "muted");
|
|
1722
|
+
if (target.health.owner) {
|
|
1723
|
+
emit.keyValue("Owner", target.health.owner, "muted");
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
if (target.source === "scan" && target.url) {
|
|
1727
|
+
emit.blank();
|
|
1728
|
+
emit.detail(`Save with ${emit.code(`vana server set-url ${target.url}`)}.`);
|
|
1729
|
+
}
|
|
1730
|
+
if (target.state !== "available") {
|
|
1731
|
+
emit.blank();
|
|
1732
|
+
emit.next("vana server set-url <url>");
|
|
1733
|
+
}
|
|
1734
|
+
emit.blank();
|
|
1735
|
+
emit.detail(`More: ${emit.code("vana server sync")} | ${emit.code("vana server data")} | ${emit.code("vana server --help")}`);
|
|
1736
|
+
return 0;
|
|
1737
|
+
}
|
|
1738
|
+
async function runServerSetUrl(url, options) {
|
|
1739
|
+
const emit = createEmitter(options);
|
|
1740
|
+
try {
|
|
1741
|
+
new URL(url);
|
|
1742
|
+
}
|
|
1743
|
+
catch {
|
|
1744
|
+
if (options.json) {
|
|
1745
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: "Invalid URL" })}\n`);
|
|
1746
|
+
}
|
|
1747
|
+
else {
|
|
1748
|
+
emit.info(`Invalid URL: ${url}`);
|
|
1749
|
+
}
|
|
1750
|
+
return 1;
|
|
1751
|
+
}
|
|
1752
|
+
await updateCliConfig({ personalServerUrl: url });
|
|
1753
|
+
const target = await detectPersonalServerTarget();
|
|
1754
|
+
if (options.json) {
|
|
1755
|
+
process.stdout.write(`${JSON.stringify({
|
|
1756
|
+
ok: true,
|
|
1757
|
+
url,
|
|
1758
|
+
reachable: target.state === "available",
|
|
1759
|
+
health: target.health,
|
|
1760
|
+
})}\n`);
|
|
1761
|
+
return 0;
|
|
1762
|
+
}
|
|
1763
|
+
emit.info(`Saved Personal Server URL: ${url}`);
|
|
1764
|
+
if (target.state === "available") {
|
|
1765
|
+
emit.info(`Server is reachable (${target.health?.version ?? "unknown version"}).`);
|
|
1766
|
+
}
|
|
1767
|
+
else {
|
|
1768
|
+
emit.info("Server is not reachable yet. It will be used when available.");
|
|
1769
|
+
}
|
|
1770
|
+
return 0;
|
|
1771
|
+
}
|
|
1772
|
+
async function runServerClearUrl(options) {
|
|
1773
|
+
const emit = createEmitter(options);
|
|
1774
|
+
const config = await readCliConfig();
|
|
1775
|
+
if (!config.personalServerUrl) {
|
|
1776
|
+
if (options.json) {
|
|
1777
|
+
process.stdout.write(`${JSON.stringify({ ok: true, cleared: false })}\n`);
|
|
1778
|
+
}
|
|
1779
|
+
else {
|
|
1780
|
+
const target = await detectPersonalServerTarget();
|
|
1781
|
+
if (target.source === "scan" && target.url) {
|
|
1782
|
+
emit.info("No saved URL to clear. Current connection is auto-detected on localhost.");
|
|
1783
|
+
emit.info(`Run ${emit.code("vana server set-url <url>")} to save a specific URL.`);
|
|
1784
|
+
}
|
|
1785
|
+
else {
|
|
1786
|
+
emit.info("No saved Personal Server URL to clear.");
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return 0;
|
|
1790
|
+
}
|
|
1791
|
+
await updateCliConfig({ personalServerUrl: undefined });
|
|
1792
|
+
if (options.json) {
|
|
1793
|
+
process.stdout.write(`${JSON.stringify({ ok: true, cleared: true })}\n`);
|
|
1794
|
+
}
|
|
1795
|
+
else {
|
|
1796
|
+
emit.info("Cleared saved Personal Server URL.");
|
|
1797
|
+
}
|
|
1798
|
+
return 0;
|
|
1799
|
+
}
|
|
1800
|
+
export function formatUptime(seconds) {
|
|
1801
|
+
if (seconds < 60)
|
|
1802
|
+
return `${Math.round(seconds)}s`;
|
|
1803
|
+
if (seconds < 3600)
|
|
1804
|
+
return `${Math.floor(seconds / 60)}m`;
|
|
1805
|
+
if (seconds < 86400) {
|
|
1806
|
+
const hours = Math.floor(seconds / 3600);
|
|
1807
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
1808
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
1809
|
+
}
|
|
1810
|
+
const days = Math.floor(seconds / 86400);
|
|
1811
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
1812
|
+
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
1813
|
+
}
|
|
1814
|
+
async function runSetup(options) {
|
|
1815
|
+
const emit = createEmitter(options);
|
|
1816
|
+
const runtime = new ManagedPlaywrightRuntime();
|
|
1817
|
+
const registrySources = await loadRegistrySources();
|
|
1818
|
+
const suggestedSource = registrySources.find((source) => source.authMode !== "legacy") ??
|
|
1819
|
+
registrySources[0];
|
|
1820
|
+
emit.title("Vana Connect setup");
|
|
1821
|
+
emit.section("Runtime");
|
|
1822
|
+
if (runtime.state === "installed") {
|
|
1823
|
+
emit.info("The local runtime is already installed.");
|
|
1824
|
+
if (runtime.runtimePath) {
|
|
1825
|
+
emit.keyValue("Browser", formatDisplayPath(runtime.runtimePath), "muted");
|
|
1826
|
+
}
|
|
1827
|
+
emit.blank();
|
|
1828
|
+
if (suggestedSource) {
|
|
1829
|
+
emit.next(`vana connect ${suggestedSource.id}`);
|
|
1830
|
+
}
|
|
1831
|
+
else {
|
|
1832
|
+
emit.next("vana connect");
|
|
1833
|
+
}
|
|
1834
|
+
emit.event({ type: "setup-check", runtime: runtime.state });
|
|
1835
|
+
return 0;
|
|
1836
|
+
}
|
|
1837
|
+
try {
|
|
1838
|
+
const result = await runtime.ensureInstalled(Boolean(options.yes));
|
|
1839
|
+
emit.success("Runtime ready.");
|
|
1840
|
+
if (result.logPath) {
|
|
1841
|
+
emit.detail(`Setup log: ${formatDisplayPath(result.logPath)}`);
|
|
1842
|
+
}
|
|
1843
|
+
emit.blank();
|
|
1844
|
+
if (suggestedSource) {
|
|
1845
|
+
emit.next(`vana connect ${suggestedSource.id}`);
|
|
1846
|
+
}
|
|
1847
|
+
else {
|
|
1848
|
+
emit.next("vana connect");
|
|
1849
|
+
}
|
|
1850
|
+
emit.event({
|
|
1851
|
+
type: "setup-complete",
|
|
1852
|
+
runtime: result.runtime,
|
|
1853
|
+
logPath: result.logPath,
|
|
1854
|
+
});
|
|
1855
|
+
return 0;
|
|
1856
|
+
}
|
|
1857
|
+
catch (error) {
|
|
1858
|
+
const message = error instanceof Error
|
|
1859
|
+
? error.message
|
|
1860
|
+
: "Vana Connect could not finish installing the local runtime.";
|
|
1861
|
+
emit.info(message);
|
|
1862
|
+
emit.event({
|
|
1863
|
+
type: "outcome",
|
|
1864
|
+
status: CliOutcomeStatus.RUNTIME_ERROR,
|
|
1865
|
+
reason: message,
|
|
1866
|
+
});
|
|
1867
|
+
return 1;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
async function runDataList(options) {
|
|
1871
|
+
const result = await queryDataList();
|
|
1872
|
+
if (options.json) {
|
|
1873
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1874
|
+
return 0;
|
|
1875
|
+
}
|
|
1876
|
+
const { datasets: datasetRecords } = result;
|
|
1877
|
+
const registrySources = await loadRegistrySources();
|
|
1878
|
+
const emit = createEmitter(options);
|
|
1879
|
+
if (datasetRecords.length === 0) {
|
|
1880
|
+
const suggestedSource = registrySources.find((source) => source.authMode !== "legacy") ??
|
|
1881
|
+
registrySources[0];
|
|
1882
|
+
emit.title("Collected data");
|
|
1883
|
+
emit.blank();
|
|
1884
|
+
emit.info(" No datasets yet.");
|
|
1885
|
+
emit.blank();
|
|
1886
|
+
if (suggestedSource) {
|
|
1887
|
+
emit.next(`vana connect ${suggestedSource.id}`);
|
|
1888
|
+
}
|
|
1889
|
+
else {
|
|
1890
|
+
emit.next("vana connect");
|
|
1891
|
+
}
|
|
1892
|
+
return 0;
|
|
1893
|
+
}
|
|
1894
|
+
emit.title(datasetRecords.length > 0
|
|
1895
|
+
? `Collected data (${datasetRecords.length})`
|
|
1896
|
+
: "Collected data");
|
|
1897
|
+
emit.blank();
|
|
1898
|
+
emit.info(joinOverviewParts([
|
|
1899
|
+
formatCountLabel("dataset", datasetRecords.length),
|
|
1900
|
+
formatCountLabel("local only", datasetRecords.filter((dataset) => dataset.dataState !== "ingested_personal_server").length),
|
|
1901
|
+
formatCountLabel("synced", datasetRecords.filter((dataset) => dataset.dataState === "ingested_personal_server").length),
|
|
1902
|
+
datasetRecords.some((dataset) => dataset.dataState === "ingest_failed")
|
|
1903
|
+
? formatCountLabel("sync failed", datasetRecords.filter((dataset) => dataset.dataState === "ingest_failed").length)
|
|
1904
|
+
: "",
|
|
1905
|
+
]));
|
|
1906
|
+
emit.blank();
|
|
1907
|
+
datasetRecords.forEach((dataset, index) => {
|
|
1908
|
+
if (index > 0) {
|
|
1909
|
+
emit.blank();
|
|
1910
|
+
}
|
|
1911
|
+
const badges = dataset.dataState === "ingested_personal_server"
|
|
1912
|
+
? [{ text: "synced", tone: "success" }]
|
|
1913
|
+
: dataset.dataState === "ingest_failed"
|
|
1914
|
+
? [{ text: "sync failed", tone: "warning" }]
|
|
1915
|
+
: [{ text: "local", tone: "muted" }];
|
|
1916
|
+
emit.sourceTitle(dataset.name ?? displaySource(dataset.source), badges);
|
|
1917
|
+
if (dataset.summary) {
|
|
1918
|
+
for (const line of dataset.summary.lines) {
|
|
1919
|
+
emit.detail(line);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
if (dataset.dataState === "ingested_personal_server") {
|
|
1923
|
+
emit.keyValue("State", "Synced to Personal Server", "success");
|
|
1924
|
+
}
|
|
1925
|
+
else if (dataset.dataState === "ingest_failed") {
|
|
1926
|
+
emit.keyValue("State", "Saved locally, sync failed", "warning");
|
|
1927
|
+
}
|
|
1928
|
+
else {
|
|
1929
|
+
emit.keyValue("State", "Saved locally", "muted");
|
|
1930
|
+
}
|
|
1931
|
+
if (dataset.lastRunAt) {
|
|
1932
|
+
emit.keyValue("Updated", formatTimestamp(dataset.lastRunAt), "muted");
|
|
1933
|
+
}
|
|
1934
|
+
if (dataset.path) {
|
|
1935
|
+
emit.keyValue("Path", formatDisplayPath(dataset.path), "muted");
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
emit.blank();
|
|
1939
|
+
if (datasetRecords.length > 0) {
|
|
1940
|
+
emit.next(`vana data show ${datasetRecords[0].source}`);
|
|
1941
|
+
}
|
|
1942
|
+
return 0;
|
|
1943
|
+
}
|
|
1944
|
+
async function runDataShow(source, options) {
|
|
1945
|
+
const result = await queryDataShow(source);
|
|
1946
|
+
if (!result.ok) {
|
|
1947
|
+
if (result.error === "dataset_not_found") {
|
|
1948
|
+
if (options.json) {
|
|
1949
|
+
process.stdout.write(`${JSON.stringify({
|
|
1950
|
+
error: result.error,
|
|
1951
|
+
source: result.source,
|
|
1952
|
+
message: result.message,
|
|
1953
|
+
nextSteps: result.nextSteps,
|
|
1954
|
+
})}\n`);
|
|
1955
|
+
}
|
|
1956
|
+
else {
|
|
1957
|
+
const emit = createEmitter(options);
|
|
1958
|
+
emit.info(result.message);
|
|
1959
|
+
emit.blank();
|
|
1960
|
+
emit.next(`vana connect ${source}`);
|
|
1961
|
+
}
|
|
1962
|
+
return 1;
|
|
1963
|
+
}
|
|
1964
|
+
// dataset_read_failed
|
|
1965
|
+
if (options.json) {
|
|
1966
|
+
process.stdout.write(`${JSON.stringify({ error: result.error, source: result.source, path: result.path, message: result.message })}\n`);
|
|
1967
|
+
}
|
|
1968
|
+
else {
|
|
1969
|
+
createEmitter(options).info(result.message);
|
|
1970
|
+
}
|
|
1971
|
+
return 1;
|
|
1972
|
+
}
|
|
1973
|
+
if (options.json) {
|
|
1974
|
+
process.stdout.write(`${JSON.stringify({
|
|
1975
|
+
source: result.source,
|
|
1976
|
+
name: result.name,
|
|
1977
|
+
path: result.path,
|
|
1978
|
+
summary: result.summary,
|
|
1979
|
+
lastRunAt: result.lastRunAt,
|
|
1980
|
+
dataState: result.dataState,
|
|
1981
|
+
nextSteps: result.nextSteps,
|
|
1982
|
+
data: result.data,
|
|
1983
|
+
})}\n`);
|
|
1984
|
+
return 0;
|
|
1985
|
+
}
|
|
1986
|
+
const emit = createEmitter(options);
|
|
1987
|
+
const state = await readCliState();
|
|
1988
|
+
const record = state.sources[source];
|
|
1989
|
+
emit.title(`${result.name} data`);
|
|
1990
|
+
emit.blank();
|
|
1991
|
+
if (result.summary) {
|
|
1992
|
+
for (const line of result.summary.lines) {
|
|
1993
|
+
emit.detail(line);
|
|
1994
|
+
}
|
|
1995
|
+
emit.blank();
|
|
1996
|
+
}
|
|
1997
|
+
emit.keyValue("Path", formatDisplayPath(result.path), "muted");
|
|
1998
|
+
if (record?.lastRunAt) {
|
|
1999
|
+
emit.keyValue("Updated", formatTimestamp(record.lastRunAt), "muted");
|
|
2000
|
+
}
|
|
2001
|
+
if (record?.dataState === "ingested_personal_server") {
|
|
2002
|
+
emit.keyValue("State", "Synced to Personal Server", "success");
|
|
2003
|
+
}
|
|
2004
|
+
else if (record?.dataState === "ingest_failed") {
|
|
2005
|
+
emit.keyValue("State", "Saved locally, sync failed", "warning");
|
|
2006
|
+
}
|
|
2007
|
+
else {
|
|
2008
|
+
emit.keyValue("State", "Saved locally", "muted");
|
|
2009
|
+
}
|
|
2010
|
+
emit.blank();
|
|
2011
|
+
if (result.datasetCount > 1) {
|
|
2012
|
+
emit.next("vana data list");
|
|
2013
|
+
}
|
|
2014
|
+
else {
|
|
2015
|
+
emit.next(`vana connect ${source}`);
|
|
2016
|
+
}
|
|
2017
|
+
return 0;
|
|
2018
|
+
}
|
|
2019
|
+
async function runDataPath(source, options) {
|
|
2020
|
+
const sourceLabels = createSourceLabelMap(await loadRegistrySources());
|
|
2021
|
+
const state = await readCliState();
|
|
2022
|
+
const resultPath = state.sources[source]?.lastResultPath;
|
|
2023
|
+
if (!resultPath) {
|
|
2024
|
+
if (options.json) {
|
|
2025
|
+
process.stdout.write(`${JSON.stringify({
|
|
2026
|
+
error: "dataset_not_found",
|
|
2027
|
+
source,
|
|
2028
|
+
name: displaySource(source, sourceLabels),
|
|
2029
|
+
message: `No collected dataset found for ${displaySource(source, sourceLabels)}. Run \`vana connect ${source}\` first.`,
|
|
2030
|
+
})}\n`);
|
|
2031
|
+
}
|
|
2032
|
+
else {
|
|
2033
|
+
createEmitter(options).info(`No collected dataset found for ${displaySource(source, sourceLabels)}. Run \`vana connect ${source}\` first.`);
|
|
2034
|
+
}
|
|
2035
|
+
return 1;
|
|
2036
|
+
}
|
|
2037
|
+
if (options.json) {
|
|
2038
|
+
process.stdout.write(`${JSON.stringify({
|
|
2039
|
+
source,
|
|
2040
|
+
name: displaySource(source, sourceLabels),
|
|
2041
|
+
path: resultPath,
|
|
2042
|
+
lastRunAt: state.sources[source]?.lastRunAt ?? null,
|
|
2043
|
+
dataState: state.sources[source]?.dataState ?? null,
|
|
2044
|
+
nextSteps: [
|
|
2045
|
+
`Inspect the dataset with \`vana data show ${source}\`.`,
|
|
2046
|
+
`Reconnect ${displaySource(source, sourceLabels)} with \`vana connect ${source}\`.`,
|
|
2047
|
+
],
|
|
2048
|
+
})}\n`);
|
|
2049
|
+
}
|
|
2050
|
+
else {
|
|
2051
|
+
process.stdout.write(`${formatDisplayPath(resultPath)}\n`);
|
|
2052
|
+
}
|
|
2053
|
+
return 0;
|
|
2054
|
+
}
|
|
2055
|
+
async function runLogs(source, options) {
|
|
2056
|
+
const sourceLabels = createSourceLabelMap(await loadRegistrySources());
|
|
2057
|
+
const state = await readCliState();
|
|
2058
|
+
const records = Object.entries(state.sources)
|
|
2059
|
+
.filter(([, entry]) => Boolean(entry?.lastLogPath))
|
|
2060
|
+
.map(([sourceId, entry]) => ({
|
|
2061
|
+
source: sourceId,
|
|
2062
|
+
name: displaySource(sourceId, sourceLabels),
|
|
2063
|
+
path: entry?.lastLogPath ?? "",
|
|
2064
|
+
lastRunAt: entry?.lastRunAt ?? null,
|
|
2065
|
+
lastRunOutcome: entry?.lastRunOutcome ?? null,
|
|
2066
|
+
dataState: (entry?.dataState === "collected_local" ||
|
|
2067
|
+
entry?.dataState === "ingested_personal_server" ||
|
|
2068
|
+
entry?.dataState === "ingest_failed"
|
|
2069
|
+
? entry.dataState
|
|
2070
|
+
: null),
|
|
2071
|
+
}))
|
|
2072
|
+
.sort(compareLogRecordOrder);
|
|
2073
|
+
const logSummary = {
|
|
2074
|
+
attentionCount: records.filter((record) => isAttentionLog(record.lastRunOutcome, record.dataState)).length,
|
|
2075
|
+
successfulCount: records.filter((record) => record.dataState === "collected_local" ||
|
|
2076
|
+
record.dataState === "ingested_personal_server").length,
|
|
2077
|
+
localCount: records.filter((record) => record.dataState === "collected_local").length,
|
|
2078
|
+
syncedCount: records.filter((record) => record.dataState === "ingested_personal_server").length,
|
|
2079
|
+
};
|
|
2080
|
+
if (source) {
|
|
2081
|
+
const match = records.find((record) => record.source === source);
|
|
2082
|
+
if (!match) {
|
|
2083
|
+
const payload = {
|
|
2084
|
+
error: "log_not_found",
|
|
2085
|
+
source,
|
|
2086
|
+
message: `No stored run log found for ${displaySource(source, sourceLabels)}.`,
|
|
2087
|
+
nextSteps: [
|
|
2088
|
+
`Run \`vana connect ${source}\` to create a new log.`,
|
|
2089
|
+
...(records.length > 0
|
|
2090
|
+
? ["Run `vana logs` to inspect other logs."]
|
|
2091
|
+
: []),
|
|
2092
|
+
],
|
|
2093
|
+
};
|
|
2094
|
+
if (options.json) {
|
|
2095
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
2096
|
+
}
|
|
2097
|
+
else {
|
|
2098
|
+
const emit = createEmitter(options);
|
|
2099
|
+
emit.info(payload.message);
|
|
2100
|
+
emit.blank();
|
|
2101
|
+
emit.next(`vana connect ${source}`);
|
|
2102
|
+
}
|
|
2103
|
+
return 1;
|
|
2104
|
+
}
|
|
2105
|
+
if (options.json) {
|
|
2106
|
+
process.stdout.write(`${JSON.stringify(match)}\n`);
|
|
2107
|
+
}
|
|
2108
|
+
else {
|
|
2109
|
+
process.stdout.write(`${formatDisplayPath(match.path)}\n`);
|
|
2110
|
+
}
|
|
2111
|
+
return 0;
|
|
2112
|
+
}
|
|
2113
|
+
const nextSteps = buildLogsNextSteps(records);
|
|
2114
|
+
if (options.json) {
|
|
2115
|
+
process.stdout.write(`${JSON.stringify({
|
|
2116
|
+
count: records.length,
|
|
2117
|
+
latestLog: records[0] ?? null,
|
|
2118
|
+
nextSteps,
|
|
2119
|
+
summary: logSummary,
|
|
2120
|
+
logs: records,
|
|
2121
|
+
})}\n`);
|
|
2122
|
+
return 0;
|
|
2123
|
+
}
|
|
2124
|
+
const emit = createEmitter(options);
|
|
2125
|
+
emit.title(records.length > 0 ? `Run logs (${records.length})` : "Run logs");
|
|
2126
|
+
emit.blank();
|
|
2127
|
+
if (records.length === 0) {
|
|
2128
|
+
emit.info("No stored run logs yet.");
|
|
2129
|
+
emit.blank();
|
|
2130
|
+
emit.next("vana connect");
|
|
2131
|
+
return 0;
|
|
2132
|
+
}
|
|
2133
|
+
emit.info(joinOverviewParts([
|
|
2134
|
+
logSummary.attentionCount > 0
|
|
2135
|
+
? formatCountLabel("need attention", logSummary.attentionCount)
|
|
2136
|
+
: "",
|
|
2137
|
+
logSummary.successfulCount > 0
|
|
2138
|
+
? formatCountLabel("successful", logSummary.successfulCount)
|
|
2139
|
+
: "",
|
|
2140
|
+
logSummary.localCount > 0
|
|
2141
|
+
? formatCountLabel("local", logSummary.localCount)
|
|
2142
|
+
: "",
|
|
2143
|
+
logSummary.syncedCount > 0
|
|
2144
|
+
? formatCountLabel("synced", logSummary.syncedCount)
|
|
2145
|
+
: "",
|
|
2146
|
+
]));
|
|
2147
|
+
emit.blank();
|
|
2148
|
+
const groups = [
|
|
2149
|
+
{
|
|
2150
|
+
title: "Needs attention",
|
|
2151
|
+
items: records.filter((record) => isAttentionLog(record.lastRunOutcome, record.dataState)),
|
|
2152
|
+
},
|
|
2153
|
+
{
|
|
2154
|
+
title: "Successful runs",
|
|
2155
|
+
items: records.filter((record) => !isAttentionLog(record.lastRunOutcome, record.dataState)),
|
|
2156
|
+
},
|
|
2157
|
+
].filter((group) => group.items.length > 0);
|
|
2158
|
+
groups.forEach((group, groupIndex) => {
|
|
2159
|
+
if (groupIndex > 0) {
|
|
2160
|
+
emit.blank();
|
|
2161
|
+
}
|
|
2162
|
+
emit.section(formatCountLabel(group.title, group.items.length));
|
|
2163
|
+
for (const record of group.items) {
|
|
2164
|
+
emit.sourceTitle(record.name, [
|
|
2165
|
+
{
|
|
2166
|
+
text: formatLogOutcomeLabel(record.lastRunOutcome, record.dataState),
|
|
2167
|
+
tone: toneForLogOutcome(record.lastRunOutcome, record.dataState),
|
|
2168
|
+
},
|
|
2169
|
+
]);
|
|
2170
|
+
emit.keyValue("Path", formatDisplayPath(record.path), "muted");
|
|
2171
|
+
if (record.lastRunAt) {
|
|
2172
|
+
emit.keyValue("Updated", formatTimestamp(record.lastRunAt), "muted");
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
});
|
|
2176
|
+
emit.blank();
|
|
2177
|
+
if (nextSteps.length > 0) {
|
|
2178
|
+
const command = extractCommand(nextSteps[0]);
|
|
2179
|
+
if (command) {
|
|
2180
|
+
emit.next(command);
|
|
2181
|
+
}
|
|
2182
|
+
else {
|
|
2183
|
+
emit.detail(`Next: ${nextSteps[0]}`);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
return 0;
|
|
2187
|
+
}
|
|
2188
|
+
async function runSourceDetail(source, options) {
|
|
2189
|
+
const emit = createEmitter(options);
|
|
2190
|
+
const registrySources = await loadRegistrySources();
|
|
2191
|
+
const state = await readCliState();
|
|
2192
|
+
const match = registrySources.find((s) => s.id === source || s.name.toLowerCase() === source.toLowerCase());
|
|
2193
|
+
if (!match) {
|
|
2194
|
+
if (options.json) {
|
|
2195
|
+
process.stdout.write(`${JSON.stringify({ error: "unknown_source", source, message: `Unknown source: ${source}. Run \`vana sources\` to see available options.` })}\n`);
|
|
2196
|
+
}
|
|
2197
|
+
else {
|
|
2198
|
+
emit.info(`Unknown source: ${source}. Run \`vana sources\` to see available options.`);
|
|
2199
|
+
}
|
|
2200
|
+
return 1;
|
|
2201
|
+
}
|
|
2202
|
+
const stored = state.sources[match.id];
|
|
2203
|
+
const metadata = await readCachedConnectorMetadata(match.id, getConnectorCacheDir());
|
|
2204
|
+
const scopes = metadata?.scopes ?? [];
|
|
2205
|
+
const sourceStatus = stored
|
|
2206
|
+
? {
|
|
2207
|
+
source: match.id,
|
|
2208
|
+
installed: Boolean(stored.connectorInstalled),
|
|
2209
|
+
sessionPresent: stored.sessionPresent ?? false,
|
|
2210
|
+
lastRunOutcome: stored.lastRunOutcome ?? null,
|
|
2211
|
+
dataState: stored.dataState,
|
|
2212
|
+
}
|
|
2213
|
+
: undefined;
|
|
2214
|
+
const badge = sourceStatus ? getSourceBadge(sourceStatus) : undefined;
|
|
2215
|
+
if (options.json) {
|
|
2216
|
+
process.stdout.write(`${JSON.stringify({
|
|
2217
|
+
id: match.id,
|
|
2218
|
+
name: match.name,
|
|
2219
|
+
company: match.company,
|
|
2220
|
+
description: match.description,
|
|
2221
|
+
version: match.version ?? stored?.connectorVersion,
|
|
2222
|
+
exportFrequency: match.exportFrequency ?? stored?.exportFrequency,
|
|
2223
|
+
authMode: match.authMode,
|
|
2224
|
+
scopes,
|
|
2225
|
+
scopeLabels: scopes.map((s) => s.label),
|
|
2226
|
+
connectorVersion: stored?.connectorVersion,
|
|
2227
|
+
lastCollectedAt: stored?.lastCollectedAt,
|
|
2228
|
+
dataState: stored?.dataState,
|
|
2229
|
+
})}\n`);
|
|
2230
|
+
return 0;
|
|
2231
|
+
}
|
|
2232
|
+
const iconPrefix = await renderIconInline(match.id);
|
|
2233
|
+
const badgeList = [];
|
|
2234
|
+
if (badge && badge.label !== "new") {
|
|
2235
|
+
badgeList.push({ text: badge.label, tone: badge.style });
|
|
2236
|
+
}
|
|
2237
|
+
emit.sourceTitle(`${iconPrefix}${match.name}`, badgeList);
|
|
2238
|
+
emit.blank();
|
|
2239
|
+
if (match.description) {
|
|
2240
|
+
emit.info(cleanDescription(match.description));
|
|
2241
|
+
emit.blank();
|
|
2242
|
+
}
|
|
2243
|
+
if (scopes.length > 0) {
|
|
2244
|
+
emit.section("Collects");
|
|
2245
|
+
for (const scope of scopes) {
|
|
2246
|
+
if (scope.description) {
|
|
2247
|
+
emit.keyValue(scope.label, cleanDescription(scope.description), "muted");
|
|
2248
|
+
}
|
|
2249
|
+
else {
|
|
2250
|
+
emit.bullet(scope.label);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
if (stored?.connectorVersion &&
|
|
2255
|
+
match.version &&
|
|
2256
|
+
stored.connectorVersion !== match.version) {
|
|
2257
|
+
emit.blank();
|
|
2258
|
+
emit.detail(`A newer connector version is available (${match.version}). Reconnect to update.`);
|
|
2259
|
+
}
|
|
2260
|
+
emit.blank();
|
|
2261
|
+
emit.next(`vana connect ${match.id}`);
|
|
2262
|
+
return 0;
|
|
2263
|
+
}
|
|
2264
|
+
async function runCollect(source, options) {
|
|
2265
|
+
const emit = createEmitter(options);
|
|
2266
|
+
const state = await readCliState();
|
|
2267
|
+
const stored = state.sources[source];
|
|
2268
|
+
if (!stored || !stored.connectorInstalled) {
|
|
2269
|
+
if (options.json) {
|
|
2270
|
+
process.stdout.write(`${JSON.stringify({
|
|
2271
|
+
error: "not_previously_connected",
|
|
2272
|
+
source,
|
|
2273
|
+
message: `Source "${source}" has not been connected yet. Run \`vana connect ${source}\` first.`,
|
|
2274
|
+
})}\n`);
|
|
2275
|
+
}
|
|
2276
|
+
else {
|
|
2277
|
+
emit.info(`Source "${source}" has not been connected yet. Run \`vana connect ${source}\` first.`);
|
|
2278
|
+
}
|
|
2279
|
+
return 1;
|
|
2280
|
+
}
|
|
2281
|
+
return runConnect(source, options);
|
|
2282
|
+
}
|
|
2283
|
+
function isPendingSyncDataState(dataState) {
|
|
2284
|
+
return dataState === "collected_local" || dataState === "ingest_unavailable";
|
|
2285
|
+
}
|
|
2286
|
+
function shouldRetryPendingSource(stored, mode) {
|
|
2287
|
+
if (!stored?.lastResultPath) {
|
|
2288
|
+
return false;
|
|
2289
|
+
}
|
|
2290
|
+
if (isPendingSyncDataState(stored.dataState)) {
|
|
2291
|
+
return true;
|
|
2292
|
+
}
|
|
2293
|
+
if (mode === "manual") {
|
|
2294
|
+
return (stored.dataState === "ingest_failed" ||
|
|
2295
|
+
stored.ingestScopes?.some((scope) => scope.status === "failed") === true);
|
|
2296
|
+
}
|
|
2297
|
+
return false;
|
|
2298
|
+
}
|
|
2299
|
+
function buildRetryOptions(stored, mode) {
|
|
2300
|
+
if (!stored) {
|
|
2301
|
+
return undefined;
|
|
2302
|
+
}
|
|
2303
|
+
if (mode !== "manual") {
|
|
2304
|
+
return undefined;
|
|
2305
|
+
}
|
|
2306
|
+
const failedScopes = stored.ingestScopes
|
|
2307
|
+
?.filter((scope) => scope.status === "failed")
|
|
2308
|
+
.map((scope) => scope.scope) ?? [];
|
|
2309
|
+
return failedScopes.length > 0 ? { scopes: failedScopes } : undefined;
|
|
2310
|
+
}
|
|
2311
|
+
function mergeIngestScopes(previous, current) {
|
|
2312
|
+
if (!previous && !current) {
|
|
2313
|
+
return undefined;
|
|
2314
|
+
}
|
|
2315
|
+
const merged = new Map();
|
|
2316
|
+
for (const scope of previous ?? []) {
|
|
2317
|
+
merged.set(scope.scope, { ...scope });
|
|
2318
|
+
}
|
|
2319
|
+
const now = new Date().toISOString();
|
|
2320
|
+
for (const scope of current ?? []) {
|
|
2321
|
+
merged.set(scope.scope, {
|
|
2322
|
+
scope: scope.scope,
|
|
2323
|
+
status: scope.status === "stored" ? "stored" : "failed",
|
|
2324
|
+
syncedAt: scope.status === "stored" ? now : merged.get(scope.scope)?.syncedAt,
|
|
2325
|
+
error: scope.status === "failed" ? scope.error : undefined,
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
return Array.from(merged.values()).sort((left, right) => left.scope.localeCompare(right.scope));
|
|
2329
|
+
}
|
|
2330
|
+
function deriveSyncedDataState(scopes, fallback) {
|
|
2331
|
+
if (!scopes || scopes.length === 0) {
|
|
2332
|
+
return fallback ?? "ingest_unavailable";
|
|
2333
|
+
}
|
|
2334
|
+
const storedCount = scopes.filter((scope) => scope.status === "stored").length;
|
|
2335
|
+
const failedCount = scopes.filter((scope) => scope.status === "failed").length;
|
|
2336
|
+
if (storedCount === 0 && failedCount > 0) {
|
|
2337
|
+
return "ingest_failed";
|
|
2338
|
+
}
|
|
2339
|
+
return "ingested_personal_server";
|
|
2340
|
+
}
|
|
2341
|
+
function summarizeSyncError(scopes) {
|
|
2342
|
+
const failedScopes = scopes?.filter((scope) => scope.status === "failed") ?? [];
|
|
2343
|
+
if (failedScopes.length === 0) {
|
|
2344
|
+
return null;
|
|
2345
|
+
}
|
|
2346
|
+
return failedScopes
|
|
2347
|
+
.map((scope) => `${scope.scope}: ${scope.error ?? "sync failed"}`)
|
|
2348
|
+
.join("; ");
|
|
2349
|
+
}
|
|
2350
|
+
async function syncPendingSources(target, mode) {
|
|
2351
|
+
const state = await readCliState();
|
|
2352
|
+
const pendingSources = Object.entries(state.sources).filter(([, stored]) => shouldRetryPendingSource(stored, mode));
|
|
2353
|
+
if (pendingSources.length === 0) {
|
|
2354
|
+
return { syncedCount: 0, sourceResults: [] };
|
|
2355
|
+
}
|
|
2356
|
+
let syncedCount = 0;
|
|
2357
|
+
const sourceResults = [];
|
|
2358
|
+
for (const [source, stored] of pendingSources) {
|
|
2359
|
+
if (!stored?.lastResultPath) {
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
2362
|
+
const ingestOptions = buildRetryOptions(stored, mode);
|
|
2363
|
+
const ingestEvents = await ingestResult(source, stored.lastResultPath, target, ingestOptions);
|
|
2364
|
+
const resultEvent = ingestEvents.find((event) => event.type === "ingest-complete" ||
|
|
2365
|
+
event.type === "ingest-partial" ||
|
|
2366
|
+
event.type === "ingest-failed");
|
|
2367
|
+
const mergedScopes = mergeIngestScopes(stored.ingestScopes, resultEvent?.scopeResults);
|
|
2368
|
+
const ingestCompleted = ingestEvents.some((event) => event.type === "ingest-complete");
|
|
2369
|
+
const ingestPartial = ingestEvents.some((event) => event.type === "ingest-partial");
|
|
2370
|
+
if (ingestCompleted || ingestPartial) {
|
|
2371
|
+
syncedCount++;
|
|
2372
|
+
await updateSourceState(source, {
|
|
2373
|
+
dataState: deriveSyncedDataState(mergedScopes, stored.dataState),
|
|
2374
|
+
ingestScopes: mergedScopes,
|
|
2375
|
+
lastError: summarizeSyncError(mergedScopes),
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
sourceResults.push({ source, scopeResults: resultEvent?.scopeResults });
|
|
2379
|
+
}
|
|
2380
|
+
return { syncedCount, sourceResults };
|
|
2381
|
+
}
|
|
2382
|
+
async function runCollectAll(options) {
|
|
2383
|
+
const emit = createEmitter(options);
|
|
2384
|
+
const state = await readCliState();
|
|
2385
|
+
const dueSources = Object.entries(state.sources)
|
|
2386
|
+
.filter(([, stored]) => stored?.connectorInstalled &&
|
|
2387
|
+
isCollectionDue(stored.exportFrequency, stored.lastCollectedAt))
|
|
2388
|
+
.map(([id]) => id);
|
|
2389
|
+
let exitCode = 0;
|
|
2390
|
+
for (const source of dueSources) {
|
|
2391
|
+
const result = await runConnect(source, options);
|
|
2392
|
+
if (result !== 0) {
|
|
2393
|
+
exitCode = result;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
let syncedPendingCount = 0;
|
|
2397
|
+
const target = await detectPersonalServerTarget();
|
|
2398
|
+
if (target.state === "available") {
|
|
2399
|
+
syncedPendingCount = (await syncPendingSources(target, "automatic"))
|
|
2400
|
+
.syncedCount;
|
|
2401
|
+
}
|
|
2402
|
+
if (dueSources.length === 0) {
|
|
2403
|
+
if (syncedPendingCount > 0) {
|
|
2404
|
+
if (options.json) {
|
|
2405
|
+
process.stdout.write(`${JSON.stringify({ message: `Synced ${syncedPendingCount} pending dataset(s).`, count: 0, syncedPendingCount })}\n`);
|
|
2406
|
+
}
|
|
2407
|
+
else {
|
|
2408
|
+
emit.info(`Synced ${syncedPendingCount} pending dataset(s).`);
|
|
2409
|
+
}
|
|
2410
|
+
return 0;
|
|
2411
|
+
}
|
|
2412
|
+
if (options.json) {
|
|
2413
|
+
process.stdout.write(`${JSON.stringify({ message: "No sources are due for collection.", count: 0, syncedPendingCount: 0 })}\n`);
|
|
2414
|
+
}
|
|
2415
|
+
else {
|
|
2416
|
+
emit.info("No sources are due for collection.");
|
|
2417
|
+
}
|
|
2418
|
+
return 0;
|
|
2419
|
+
}
|
|
2420
|
+
return exitCode;
|
|
2421
|
+
}
|
|
2422
|
+
async function runServerSync(options) {
|
|
2423
|
+
const emit = createEmitter(options);
|
|
2424
|
+
const target = await detectPersonalServerTarget();
|
|
2425
|
+
trackActiveTelemetryEvent("server_sync_started");
|
|
2426
|
+
if (target.state !== "available") {
|
|
2427
|
+
trackActiveTelemetryEvent("server_sync_failed", {
|
|
2428
|
+
errorClass: "personal_server_unavailable",
|
|
2429
|
+
});
|
|
2430
|
+
if (options.json) {
|
|
2431
|
+
process.stdout.write(`${JSON.stringify({
|
|
2432
|
+
error: "personal_server_unavailable",
|
|
2433
|
+
message: "Personal Server is not available. Run `vana server set-url <url>` to configure.",
|
|
2434
|
+
})}\n`);
|
|
2435
|
+
}
|
|
2436
|
+
else {
|
|
2437
|
+
emit.info("Personal Server is not available. Run `vana server set-url <url>` to configure.");
|
|
2438
|
+
}
|
|
2439
|
+
return 1;
|
|
2440
|
+
}
|
|
2441
|
+
const syncResult = await syncPendingSources(target, "manual");
|
|
2442
|
+
const storedScopeCount = syncResult.sourceResults.reduce((total, entry) => total +
|
|
2443
|
+
(entry.scopeResults?.filter((scopeResult) => scopeResult.status === "stored").length ?? 0), 0);
|
|
2444
|
+
const failedScopeCount = syncResult.sourceResults.reduce((total, entry) => total +
|
|
2445
|
+
(entry.scopeResults?.filter((scopeResult) => scopeResult.status === "failed").length ?? 0), 0);
|
|
2446
|
+
if (syncResult.sourceResults.length === 0) {
|
|
2447
|
+
trackActiveTelemetryEvent("server_sync_completed", {
|
|
2448
|
+
storedScopeCount: 0,
|
|
2449
|
+
failedScopeCount: 0,
|
|
2450
|
+
metadata: { syncedSources: 0 },
|
|
2451
|
+
});
|
|
2452
|
+
if (options.json) {
|
|
2453
|
+
process.stdout.write(`${JSON.stringify({ message: "No pending datasets to sync.", syncedCount: 0 })}\n`);
|
|
2454
|
+
}
|
|
2455
|
+
else {
|
|
2456
|
+
emit.info("No pending datasets to sync.");
|
|
2457
|
+
}
|
|
2458
|
+
return 0;
|
|
2459
|
+
}
|
|
2460
|
+
if (options.json) {
|
|
2461
|
+
process.stdout.write(`${JSON.stringify({ message: `Synced ${syncResult.syncedCount} dataset(s).`, syncedCount: syncResult.syncedCount })}\n`);
|
|
2462
|
+
}
|
|
2463
|
+
else {
|
|
2464
|
+
// Show per-scope results with scope manifest style
|
|
2465
|
+
const renderer = createHumanRenderer();
|
|
2466
|
+
for (const entry of syncResult.sourceResults) {
|
|
2467
|
+
if (entry.scopeResults && entry.scopeResults.length > 0) {
|
|
2468
|
+
emit.info(`${entry.source}:`);
|
|
2469
|
+
for (const sr of entry.scopeResults) {
|
|
2470
|
+
if (sr.status === "stored") {
|
|
2471
|
+
emit.info(` ${renderer.theme.success("\u2713")} ${sr.scope}`);
|
|
2472
|
+
}
|
|
2473
|
+
else {
|
|
2474
|
+
const errDetail = sr.error ? humanizeIssue(sr.error) : "Failed";
|
|
2475
|
+
emit.info(` ${renderer.theme.error("\u2717")} ${sr.scope} ${renderer.theme.muted(`\u2014 ${errDetail}`)}`);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
emit.blank();
|
|
2481
|
+
const allStored = syncResult.sourceResults.every((entry) => !entry.scopeResults ||
|
|
2482
|
+
entry.scopeResults.every((sr) => sr.status === "stored"));
|
|
2483
|
+
emit.success(`Synced ${syncResult.syncedCount} dataset(s).`);
|
|
2484
|
+
emit.blank();
|
|
2485
|
+
if (allStored) {
|
|
2486
|
+
emit.next("vana data list");
|
|
2487
|
+
}
|
|
2488
|
+
else {
|
|
2489
|
+
emit.next("vana server sync");
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
trackActiveTelemetryEvent("server_sync_completed", {
|
|
2493
|
+
storedScopeCount,
|
|
2494
|
+
failedScopeCount,
|
|
2495
|
+
metadata: { syncedSources: syncResult.syncedCount },
|
|
2496
|
+
});
|
|
2497
|
+
return 0;
|
|
2498
|
+
}
|
|
2499
|
+
async function runServerData(scope, options) {
|
|
2500
|
+
const emit = createEmitter(options);
|
|
2501
|
+
const target = await detectPersonalServerTarget();
|
|
2502
|
+
const state = await readCliState();
|
|
2503
|
+
// Gather locally-known scopes from state
|
|
2504
|
+
const localScopes = [];
|
|
2505
|
+
for (const [src, stored] of Object.entries(state.sources)) {
|
|
2506
|
+
if (stored?.ingestScopes) {
|
|
2507
|
+
for (const is of stored.ingestScopes) {
|
|
2508
|
+
localScopes.push({ scope: is.scope, source: src, status: is.status });
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
// If PS is available, try to list remote scopes via client
|
|
2513
|
+
let remoteScopes = [];
|
|
2514
|
+
let didQueryRemoteScopes = false;
|
|
2515
|
+
let remoteScopeFallbackReason;
|
|
2516
|
+
if (target.state === "available" && target.url) {
|
|
2517
|
+
const auth = resolvePersonalServerAuthConfig(target.url);
|
|
2518
|
+
try {
|
|
2519
|
+
if (auth?.type === "bearerToken") {
|
|
2520
|
+
const { createPersonalServerClient: createClient } = await import("../personal-server/client.js");
|
|
2521
|
+
const client = createClient({
|
|
2522
|
+
url: target.url,
|
|
2523
|
+
auth,
|
|
2524
|
+
});
|
|
2525
|
+
didQueryRemoteScopes = true;
|
|
2526
|
+
remoteScopes = await client.listScopes(scope);
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
catch (err) {
|
|
2530
|
+
remoteScopeFallbackReason =
|
|
2531
|
+
err instanceof Error ? err.message : "unknown error";
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
// Use remote scopes if available, otherwise fall back to local
|
|
2535
|
+
const scopeList = didQueryRemoteScopes
|
|
2536
|
+
? remoteScopes.map((s) => ({
|
|
2537
|
+
scope: s.scope,
|
|
2538
|
+
detail: `${s.count} version${s.count !== 1 ? "s" : ""}`,
|
|
2539
|
+
}))
|
|
2540
|
+
: localScopes
|
|
2541
|
+
.filter((s) => s.status === "stored")
|
|
2542
|
+
.filter((s) => !scope || s.scope.startsWith(scope))
|
|
2543
|
+
.map((s) => ({ scope: s.scope, detail: "1 version" }));
|
|
2544
|
+
if (options.json) {
|
|
2545
|
+
process.stdout.write(`${JSON.stringify({
|
|
2546
|
+
count: scopeList.length,
|
|
2547
|
+
scopes: scopeList,
|
|
2548
|
+
source: didQueryRemoteScopes ? "remote" : "local",
|
|
2549
|
+
...(remoteScopeFallbackReason ? { remoteScopeFallbackReason } : {}),
|
|
2550
|
+
})}\n`);
|
|
2551
|
+
return 0;
|
|
2552
|
+
}
|
|
2553
|
+
if (scopeList.length === 0) {
|
|
2554
|
+
emit.info(didQueryRemoteScopes
|
|
2555
|
+
? "No data on your Personal Server."
|
|
2556
|
+
: "No scopes found.");
|
|
2557
|
+
if (target.state !== "available") {
|
|
2558
|
+
emit.detail("Personal Server is not available. Showing locally-known scopes only.");
|
|
2559
|
+
}
|
|
2560
|
+
return 0;
|
|
2561
|
+
}
|
|
2562
|
+
for (const entry of scopeList) {
|
|
2563
|
+
emit.keyValue(entry.scope, entry.detail, "muted");
|
|
2564
|
+
}
|
|
2565
|
+
if (!didQueryRemoteScopes && localScopes.length > 0) {
|
|
2566
|
+
emit.blank();
|
|
2567
|
+
emit.detail("Showing locally-known scopes. Connect your Personal Server for live data.");
|
|
2568
|
+
}
|
|
2569
|
+
return 0;
|
|
2570
|
+
}
|
|
2571
|
+
function getSourceBadge(source) {
|
|
2572
|
+
if (source.dataState === "collected_local" ||
|
|
2573
|
+
source.dataState === "ingested_personal_server" ||
|
|
2574
|
+
source.dataState === "ingest_failed") {
|
|
2575
|
+
return { label: "connected", style: "success" };
|
|
2576
|
+
}
|
|
2577
|
+
if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT ||
|
|
2578
|
+
source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
|
|
2579
|
+
return { label: "needs login", style: "warning" };
|
|
2580
|
+
}
|
|
2581
|
+
if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR ||
|
|
2582
|
+
source.lastRunOutcome === CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR) {
|
|
2583
|
+
return { label: "error", style: "error" };
|
|
2584
|
+
}
|
|
2585
|
+
return { label: "new", style: "muted" };
|
|
2586
|
+
}
|
|
2587
|
+
function isCollectionDue(frequency, lastCollectedAt) {
|
|
2588
|
+
if (!frequency || !lastCollectedAt) {
|
|
2589
|
+
return true;
|
|
2590
|
+
}
|
|
2591
|
+
const lastMs = new Date(lastCollectedAt).getTime();
|
|
2592
|
+
if (Number.isNaN(lastMs)) {
|
|
2593
|
+
return true;
|
|
2594
|
+
}
|
|
2595
|
+
const now = Date.now();
|
|
2596
|
+
const elapsed = now - lastMs;
|
|
2597
|
+
const intervalMs = parseFrequencyToMs(frequency);
|
|
2598
|
+
return elapsed >= intervalMs;
|
|
2599
|
+
}
|
|
2600
|
+
function parseFrequencyToMs(frequency) {
|
|
2601
|
+
const lower = frequency.toLowerCase().trim();
|
|
2602
|
+
if (lower === "daily") {
|
|
2603
|
+
return 24 * 60 * 60 * 1000;
|
|
2604
|
+
}
|
|
2605
|
+
if (lower === "weekly") {
|
|
2606
|
+
return 7 * 24 * 60 * 60 * 1000;
|
|
2607
|
+
}
|
|
2608
|
+
if (lower === "monthly") {
|
|
2609
|
+
return 30 * 24 * 60 * 60 * 1000;
|
|
2610
|
+
}
|
|
2611
|
+
const match = /^(\d+)\s*(h|d|m|w)$/i.exec(lower);
|
|
2612
|
+
if (match) {
|
|
2613
|
+
const value = parseInt(match[1], 10);
|
|
2614
|
+
const unit = match[2].toLowerCase();
|
|
2615
|
+
if (unit === "h")
|
|
2616
|
+
return value * 60 * 60 * 1000;
|
|
2617
|
+
if (unit === "d")
|
|
2618
|
+
return value * 24 * 60 * 60 * 1000;
|
|
2619
|
+
if (unit === "w")
|
|
2620
|
+
return value * 7 * 24 * 60 * 60 * 1000;
|
|
2621
|
+
if (unit === "m")
|
|
2622
|
+
return value * 30 * 24 * 60 * 60 * 1000;
|
|
2623
|
+
}
|
|
2624
|
+
// Default to daily if unparseable.
|
|
2625
|
+
return 24 * 60 * 60 * 1000;
|
|
2626
|
+
}
|
|
2627
|
+
async function renderIconInline(source) {
|
|
2628
|
+
const iconPath = findCachedIconPath(source);
|
|
2629
|
+
if (!iconPath) {
|
|
2630
|
+
return "";
|
|
2631
|
+
}
|
|
2632
|
+
try {
|
|
2633
|
+
// terminal-image is optional — not in package.json dependencies.
|
|
2634
|
+
// The `as string` cast prevents TypeScript from resolving the module at compile time.
|
|
2635
|
+
const terminalImage = (await import("terminal-image"));
|
|
2636
|
+
const imageBuffer = await fsp.readFile(iconPath);
|
|
2637
|
+
return await terminalImage.default.buffer(imageBuffer, {
|
|
2638
|
+
width: 2,
|
|
2639
|
+
height: 1,
|
|
2640
|
+
});
|
|
2641
|
+
}
|
|
2642
|
+
catch {
|
|
2643
|
+
return "";
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
function findCachedIconPath(source) {
|
|
2647
|
+
const cacheDir = getConnectorCacheDir();
|
|
2648
|
+
const extensions = [".png", ".svg", ".jpg", ".jpeg", ".webp"];
|
|
2649
|
+
for (const ext of extensions) {
|
|
2650
|
+
const candidate = path.join(cacheDir, `${source}.icon${ext}`);
|
|
2651
|
+
if (fs.existsSync(candidate)) {
|
|
2652
|
+
return candidate;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
return null;
|
|
2656
|
+
}
|
|
2657
|
+
function createEmitter(options) {
|
|
2658
|
+
const renderer = createHumanRenderer();
|
|
2659
|
+
return {
|
|
2660
|
+
event(event) {
|
|
2661
|
+
getActiveTelemetrySession()?.trackCliEvent(event);
|
|
2662
|
+
if (options.json) {
|
|
2663
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
2664
|
+
}
|
|
2665
|
+
},
|
|
2666
|
+
info(message) {
|
|
2667
|
+
if (options.json || options.quiet) {
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
process.stdout.write(`${message}\n`);
|
|
2671
|
+
},
|
|
2672
|
+
blank() {
|
|
2673
|
+
if (options.json || options.quiet) {
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
process.stdout.write("\n");
|
|
2677
|
+
},
|
|
2678
|
+
title(message) {
|
|
2679
|
+
if (options.json || options.quiet) {
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2682
|
+
process.stdout.write(`${renderer.title(message)}\n`);
|
|
2683
|
+
},
|
|
2684
|
+
success(message) {
|
|
2685
|
+
if (options.json || options.quiet) {
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
process.stdout.write(`${renderer.success(message)}\n`);
|
|
2689
|
+
},
|
|
2690
|
+
section(message) {
|
|
2691
|
+
if (options.json || options.quiet) {
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
process.stdout.write(`${renderer.section(message)}\n`);
|
|
2695
|
+
},
|
|
2696
|
+
keyValue(label, value, tone = "muted") {
|
|
2697
|
+
if (options.json || options.quiet) {
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
process.stdout.write(`${renderer.keyValue(label, value, tone)}\n`);
|
|
2701
|
+
},
|
|
2702
|
+
detail(message) {
|
|
2703
|
+
if (options.json || options.quiet) {
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
process.stdout.write(`${renderer.detail(message)}\n`);
|
|
2707
|
+
},
|
|
2708
|
+
next(command) {
|
|
2709
|
+
if (options.json || options.quiet) {
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
process.stdout.write(` ${renderer.theme.muted("Next:")} ${renderer.theme.code(command)}\n`);
|
|
2713
|
+
},
|
|
2714
|
+
bullet(message) {
|
|
2715
|
+
if (options.json || options.quiet) {
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
process.stdout.write(`${renderer.bullet(message)}\n`);
|
|
2719
|
+
},
|
|
2720
|
+
sourceTitle(name, badges = []) {
|
|
2721
|
+
if (options.json || options.quiet) {
|
|
2722
|
+
return;
|
|
2723
|
+
}
|
|
2724
|
+
process.stdout.write(`${renderer.sourceTitle(name, badges.map((badge) => renderer.badge(badge.text, badge.tone)))}\n`);
|
|
2725
|
+
},
|
|
2726
|
+
badge(text, tone = "muted") {
|
|
2727
|
+
return renderer.badge(text, tone);
|
|
2728
|
+
},
|
|
2729
|
+
code(text) {
|
|
2730
|
+
return renderer.theme.code(text);
|
|
2731
|
+
},
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
export function displaySource(source, labels = {}) {
|
|
2735
|
+
return labels[source] ?? source.charAt(0).toUpperCase() + source.slice(1);
|
|
2736
|
+
}
|
|
2737
|
+
function formatCountLabel(label, count) {
|
|
2738
|
+
const normalizedLabel = label.charAt(0).toUpperCase() + label.slice(1);
|
|
2739
|
+
return `${normalizedLabel} (${count})`;
|
|
2740
|
+
}
|
|
2741
|
+
function joinOverviewParts(parts) {
|
|
2742
|
+
return parts.filter(Boolean).join(" · ");
|
|
2743
|
+
}
|
|
2744
|
+
function humanizeField(value) {
|
|
2745
|
+
return value
|
|
2746
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
2747
|
+
.replace(/[_-]/g, " ")
|
|
2748
|
+
.replace(/^\w/, (match) => match.toUpperCase());
|
|
2749
|
+
}
|
|
2750
|
+
export function humanizeIssue(message) {
|
|
2751
|
+
if (/checksum|mismatch/i.test(message)) {
|
|
2752
|
+
return "Connector is out of date. Will auto-update on next connect.";
|
|
2753
|
+
}
|
|
2754
|
+
const transportSummary = summarizeTransportError(message);
|
|
2755
|
+
if (transportSummary) {
|
|
2756
|
+
return transportSummary;
|
|
2757
|
+
}
|
|
2758
|
+
return message;
|
|
2759
|
+
}
|
|
2760
|
+
function summarizeTransportError(message) {
|
|
2761
|
+
const httpMatch = message.match(/^HTTP\s+\d+:\s*(.+)$/s);
|
|
2762
|
+
if (!httpMatch) {
|
|
2763
|
+
return null;
|
|
2764
|
+
}
|
|
2765
|
+
const payload = httpMatch[1].trim();
|
|
2766
|
+
const parsed = parseTransportErrorPayload(payload);
|
|
2767
|
+
if (parsed) {
|
|
2768
|
+
return parsed.message;
|
|
2769
|
+
}
|
|
2770
|
+
const messageMatch = payload.match(/"message"\s*:\s*"([^"]+)"/);
|
|
2771
|
+
if (messageMatch) {
|
|
2772
|
+
return messageMatch[1];
|
|
2773
|
+
}
|
|
2774
|
+
return "Request failed";
|
|
2775
|
+
}
|
|
2776
|
+
function parseTransportErrorPayload(payload) {
|
|
2777
|
+
try {
|
|
2778
|
+
const parsed = JSON.parse(payload);
|
|
2779
|
+
const rawError = typeof parsed.error === "string"
|
|
2780
|
+
? parsed.error
|
|
2781
|
+
: parsed.error && typeof parsed.error === "object"
|
|
2782
|
+
? (parsed.error.errorCode ??
|
|
2783
|
+
parsed.error.code)
|
|
2784
|
+
: undefined;
|
|
2785
|
+
const rawMessage = typeof parsed.message === "string"
|
|
2786
|
+
? parsed.message
|
|
2787
|
+
: parsed.error && typeof parsed.error === "object"
|
|
2788
|
+
? parsed.error.message
|
|
2789
|
+
: undefined;
|
|
2790
|
+
const code = typeof rawError === "string" || typeof rawError === "number"
|
|
2791
|
+
? String(rawError)
|
|
2792
|
+
: undefined;
|
|
2793
|
+
const message = typeof rawMessage === "string" ? rawMessage : "Request failed";
|
|
2794
|
+
return { code, message: summarizeTransportCode(code, message) };
|
|
2795
|
+
}
|
|
2796
|
+
catch {
|
|
2797
|
+
return null;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
function summarizeTransportCode(code, message) {
|
|
2801
|
+
if (code === "MISSING_AUTH" || /missing authentication/i.test(message)) {
|
|
2802
|
+
return "Authentication required";
|
|
2803
|
+
}
|
|
2804
|
+
if (code === "NO_SCHEMA" || /no schema registered/i.test(message)) {
|
|
2805
|
+
return "No schema registered";
|
|
2806
|
+
}
|
|
2807
|
+
if (code === "INVALID_SCOPE" || /scope must be/i.test(message)) {
|
|
2808
|
+
return "Unsupported scope";
|
|
2809
|
+
}
|
|
2810
|
+
return message;
|
|
2811
|
+
}
|
|
2812
|
+
function formatHumanSourceMessage(message, source, displayName) {
|
|
2813
|
+
if (!message || source === displayName) {
|
|
2814
|
+
return message;
|
|
2815
|
+
}
|
|
2816
|
+
return message.replace(new RegExp(`\\b${escapeRegExp(source)}\\b`, "gi"), displayName);
|
|
2817
|
+
}
|
|
2818
|
+
function escapeRegExp(value) {
|
|
2819
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2820
|
+
}
|
|
2821
|
+
function getErrorLogPath(error) {
|
|
2822
|
+
if (error &&
|
|
2823
|
+
typeof error === "object" &&
|
|
2824
|
+
"logPath" in error &&
|
|
2825
|
+
typeof error.logPath === "string") {
|
|
2826
|
+
return error.logPath;
|
|
2827
|
+
}
|
|
2828
|
+
return null;
|
|
2829
|
+
}
|
|
2830
|
+
export async function gatherSourceStatuses(storedSources, metadata = {}) {
|
|
2831
|
+
const installedFiles = await listInstalledConnectorFiles();
|
|
2832
|
+
const sourceNames = new Set([
|
|
2833
|
+
...Object.keys(storedSources),
|
|
2834
|
+
...installedFiles.map((file) => file.source),
|
|
2835
|
+
]);
|
|
2836
|
+
return [...sourceNames]
|
|
2837
|
+
.map((source) => {
|
|
2838
|
+
const stored = storedSources[source] ?? {};
|
|
2839
|
+
const installed = installedFiles.some((file) => file.source === source);
|
|
2840
|
+
const details = metadata[source];
|
|
2841
|
+
const dataState = stored.dataState === "ingested_personal_server"
|
|
2842
|
+
? "ingested_personal_server"
|
|
2843
|
+
: stored.dataState === "ingest_unavailable"
|
|
2844
|
+
? "ingest_unavailable"
|
|
2845
|
+
: stored.dataState === "ingest_failed"
|
|
2846
|
+
? "ingest_failed"
|
|
2847
|
+
: stored.dataState === "collected_local"
|
|
2848
|
+
? "collected_local"
|
|
2849
|
+
: "none";
|
|
2850
|
+
const ingestScopes = stored.ingestScopes;
|
|
2851
|
+
const syncedScopeCount = ingestScopes?.filter((s) => s.status === "stored").length ?? 0;
|
|
2852
|
+
const failedScopeCount = ingestScopes?.filter((s) => s.status === "failed").length ?? 0;
|
|
2853
|
+
const isOverdue = stored.lastCollectedAt && stored.exportFrequency
|
|
2854
|
+
? isCollectionDue(stored.exportFrequency, stored.lastCollectedAt)
|
|
2855
|
+
: undefined;
|
|
2856
|
+
const suggestedNextCollectionAt = stored.lastCollectedAt && stored.exportFrequency
|
|
2857
|
+
? new Date(new Date(stored.lastCollectedAt).getTime() +
|
|
2858
|
+
parseFrequencyToMs(stored.exportFrequency)).toISOString()
|
|
2859
|
+
: undefined;
|
|
2860
|
+
return {
|
|
2861
|
+
source,
|
|
2862
|
+
name: details?.name,
|
|
2863
|
+
company: details?.company,
|
|
2864
|
+
description: details?.description,
|
|
2865
|
+
authMode: details?.authMode ?? inferInstalledAuthMode(installedFiles, source),
|
|
2866
|
+
connectorVersion: stored.connectorVersion,
|
|
2867
|
+
exportFrequency: stored.exportFrequency,
|
|
2868
|
+
lastCollectedAt: stored.lastCollectedAt,
|
|
2869
|
+
connectionHealth: stored.connectionHealth,
|
|
2870
|
+
connectionHealthChangedAt: stored.connectionHealthChangedAt,
|
|
2871
|
+
connectionHealthReason: stored.connectionHealthReason,
|
|
2872
|
+
connectionHealthRetryable: stored.connectionHealthRetryable,
|
|
2873
|
+
installed,
|
|
2874
|
+
sessionPresent: stored.sessionPresent ?? false,
|
|
2875
|
+
lastRunAt: stored.lastRunAt ?? null,
|
|
2876
|
+
lastRunOutcome: stored.lastRunOutcome ?? null,
|
|
2877
|
+
dataState,
|
|
2878
|
+
lastError: stored.lastError ?? null,
|
|
2879
|
+
lastResultPath: stored.lastResultPath ?? null,
|
|
2880
|
+
lastLogPath: stored.lastLogPath ?? null,
|
|
2881
|
+
ingestScopes,
|
|
2882
|
+
syncedScopeCount: syncedScopeCount > 0 ? syncedScopeCount : undefined,
|
|
2883
|
+
failedScopeCount: failedScopeCount > 0 ? failedScopeCount : undefined,
|
|
2884
|
+
suggestedNextCollectionAt,
|
|
2885
|
+
isOverdue,
|
|
2886
|
+
};
|
|
2887
|
+
})
|
|
2888
|
+
.sort(compareSourceStatusOrder);
|
|
2889
|
+
}
|
|
2890
|
+
export async function listInstalledConnectorFiles() {
|
|
2891
|
+
const connectorsDir = getConnectorCacheDir();
|
|
2892
|
+
try {
|
|
2893
|
+
const results = [];
|
|
2894
|
+
const entries = await fsp.readdir(connectorsDir, { withFileTypes: true });
|
|
2895
|
+
for (const entry of entries) {
|
|
2896
|
+
if (!entry.isDirectory()) {
|
|
2897
|
+
continue;
|
|
2898
|
+
}
|
|
2899
|
+
const companyDir = path.join(connectorsDir, entry.name);
|
|
2900
|
+
const files = await fsp.readdir(companyDir);
|
|
2901
|
+
for (const file of files) {
|
|
2902
|
+
if (!file.endsWith("-playwright.js")) {
|
|
2903
|
+
continue;
|
|
2904
|
+
}
|
|
2905
|
+
results.push({
|
|
2906
|
+
source: file.replace(/-playwright\.js$/, ""),
|
|
2907
|
+
path: path.join(companyDir, file),
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
return results;
|
|
2912
|
+
}
|
|
2913
|
+
catch {
|
|
2914
|
+
return [];
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
function formatSourceStatusDetails(source) {
|
|
2918
|
+
const details = [];
|
|
2919
|
+
const displayName = source.name ?? displaySource(source.source);
|
|
2920
|
+
if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
|
|
2921
|
+
details.push(source.lastError
|
|
2922
|
+
? {
|
|
2923
|
+
kind: "text",
|
|
2924
|
+
message: `${source.lastError}. Run \`vana connect ${source.source}\` interactively.`,
|
|
2925
|
+
}
|
|
2926
|
+
: {
|
|
2927
|
+
kind: "text",
|
|
2928
|
+
message: `Run \`vana connect ${source.source}\` interactively.`,
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
|
|
2932
|
+
details.push({
|
|
2933
|
+
kind: "text",
|
|
2934
|
+
message: `Run \`vana connect ${source.source}\` without \`--no-input\` to complete the manual browser step.`,
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2937
|
+
if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
|
|
2938
|
+
details.push(source.lastError
|
|
2939
|
+
? {
|
|
2940
|
+
kind: "text",
|
|
2941
|
+
message: formatHumanSourceMessage(source.lastError, source.source, displayName),
|
|
2942
|
+
}
|
|
2943
|
+
: {
|
|
2944
|
+
kind: "text",
|
|
2945
|
+
message: "The last connector run failed.",
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
|
|
2949
|
+
details.push(source.lastError
|
|
2950
|
+
? {
|
|
2951
|
+
kind: "text",
|
|
2952
|
+
message: formatHumanSourceMessage(source.lastError, source.source, displayName),
|
|
2953
|
+
}
|
|
2954
|
+
: {
|
|
2955
|
+
kind: "text",
|
|
2956
|
+
message: "No connector is available for this source.",
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
if (!source.lastRunOutcome && source.installed) {
|
|
2960
|
+
details.push({
|
|
2961
|
+
kind: "text",
|
|
2962
|
+
message: `Run \`vana connect ${source.source}\` to collect data.`,
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
if (source.lastRunOutcome === CliOutcomeStatus.CONNECTED_LOCAL_ONLY &&
|
|
2966
|
+
source.lastResultPath) {
|
|
2967
|
+
details.push({
|
|
2968
|
+
kind: "text",
|
|
2969
|
+
message: `Inspect the latest local dataset with \`vana data show ${source.source}\`.`,
|
|
2970
|
+
});
|
|
2971
|
+
}
|
|
2972
|
+
if (source.sessionPresent &&
|
|
2973
|
+
(source.lastRunOutcome === CliOutcomeStatus.CONNECTED_LOCAL_ONLY ||
|
|
2974
|
+
source.lastRunOutcome === CliOutcomeStatus.CONNECTED_AND_INGESTED ||
|
|
2975
|
+
source.lastRunOutcome === CliOutcomeStatus.INGEST_FAILED)) {
|
|
2976
|
+
details.push({
|
|
2977
|
+
kind: "row",
|
|
2978
|
+
label: "Session",
|
|
2979
|
+
value: "Session cached.",
|
|
2980
|
+
tone: "muted",
|
|
2981
|
+
});
|
|
2982
|
+
}
|
|
2983
|
+
if (source.lastRunOutcome === CliOutcomeStatus.CONNECTED_AND_INGESTED) {
|
|
2984
|
+
details.push({
|
|
2985
|
+
kind: "text",
|
|
2986
|
+
message: `Inspect the latest local dataset with \`vana data show ${source.source}\` or use your Personal Server copy.`,
|
|
2987
|
+
});
|
|
2988
|
+
}
|
|
2989
|
+
if (source.lastRunOutcome === CliOutcomeStatus.INGEST_FAILED) {
|
|
2990
|
+
details.push(source.lastError
|
|
2991
|
+
? {
|
|
2992
|
+
kind: "text",
|
|
2993
|
+
message: `${source.lastError} Inspect the local dataset with \`vana data show ${source.source}\`.`,
|
|
2994
|
+
}
|
|
2995
|
+
: {
|
|
2996
|
+
kind: "text",
|
|
2997
|
+
message: `Personal Server sync failed. Inspect the local dataset with \`vana data show ${source.source}\`.`,
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
if (source.dataState === "ingested_personal_server") {
|
|
3001
|
+
details.push({
|
|
3002
|
+
kind: "row",
|
|
3003
|
+
label: "State",
|
|
3004
|
+
value: "Synced to Personal Server",
|
|
3005
|
+
tone: "success",
|
|
3006
|
+
});
|
|
3007
|
+
}
|
|
3008
|
+
else if (source.dataState === "ingest_unavailable") {
|
|
3009
|
+
details.push({
|
|
3010
|
+
kind: "row",
|
|
3011
|
+
label: "State",
|
|
3012
|
+
value: "Pending sync to Personal Server",
|
|
3013
|
+
tone: "warning",
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
else if (source.dataState === "ingest_failed") {
|
|
3017
|
+
details.push({
|
|
3018
|
+
kind: "row",
|
|
3019
|
+
label: "State",
|
|
3020
|
+
value: "Saved locally, sync failed",
|
|
3021
|
+
tone: "warning",
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
3024
|
+
else if (source.dataState === "collected_local") {
|
|
3025
|
+
details.push({
|
|
3026
|
+
kind: "row",
|
|
3027
|
+
label: "State",
|
|
3028
|
+
value: "Saved locally",
|
|
3029
|
+
tone: "muted",
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
if (source.connectionHealthReason && source.connectionHealth !== "healthy") {
|
|
3033
|
+
details.push({
|
|
3034
|
+
kind: "row",
|
|
3035
|
+
label: "Cause",
|
|
3036
|
+
value: source.connectionHealthReason,
|
|
3037
|
+
tone: "muted",
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
if (source.lastRunAt) {
|
|
3041
|
+
details.push({
|
|
3042
|
+
kind: "row",
|
|
3043
|
+
label: "Updated",
|
|
3044
|
+
value: `${formatTimestamp(source.lastRunAt)} (${formatRelativeTime(source.lastRunAt)})`,
|
|
3045
|
+
tone: "muted",
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
if (source.lastResultPath && source.dataState !== "none") {
|
|
3049
|
+
details.push({
|
|
3050
|
+
kind: "row",
|
|
3051
|
+
label: "Path",
|
|
3052
|
+
value: formatDisplayPath(source.lastResultPath),
|
|
3053
|
+
tone: "muted",
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
3056
|
+
if (source.lastLogPath &&
|
|
3057
|
+
source.lastRunOutcome &&
|
|
3058
|
+
source.lastRunOutcome !== CliOutcomeStatus.CONNECTED_LOCAL_ONLY &&
|
|
3059
|
+
source.lastRunOutcome !== CliOutcomeStatus.CONNECTED_AND_INGESTED) {
|
|
3060
|
+
details.push({
|
|
3061
|
+
kind: "row",
|
|
3062
|
+
label: "Run log",
|
|
3063
|
+
value: formatDisplayPath(source.lastLogPath),
|
|
3064
|
+
tone: "muted",
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
return details;
|
|
3068
|
+
}
|
|
3069
|
+
export function buildStatusNextSteps(sources, sourceLabels = {}, runtime = "unhealthy", availableSources = []) {
|
|
3070
|
+
const nextSteps = [];
|
|
3071
|
+
const attentionSources = [...sources]
|
|
3072
|
+
.filter(isSourceAttention)
|
|
3073
|
+
.sort(compareAttentionPriority);
|
|
3074
|
+
const highestPriority = attentionSources[0] ?? [...sources].sort(compareSourceStatusOrder)[0];
|
|
3075
|
+
const connectedSources = sources.filter((source) => source.dataState === "collected_local" ||
|
|
3076
|
+
source.dataState === "ingested_personal_server" ||
|
|
3077
|
+
source.dataState === "ingest_failed" ||
|
|
3078
|
+
source.dataState === "ingest_unavailable");
|
|
3079
|
+
const needsAttention = attentionSources.length > 0;
|
|
3080
|
+
const highestPriorityLabel = highestPriority
|
|
3081
|
+
? displaySource(highestPriority.source, sourceLabels)
|
|
3082
|
+
: null;
|
|
3083
|
+
const suggestedSource = availableSources.find((source) => source.authMode !== "legacy") ??
|
|
3084
|
+
availableSources[0];
|
|
3085
|
+
if (!highestPriority) {
|
|
3086
|
+
if (runtime === "installed") {
|
|
3087
|
+
if (suggestedSource) {
|
|
3088
|
+
nextSteps.push(`Connect ${suggestedSource.name} with \`vana connect ${suggestedSource.id}\`.`);
|
|
3089
|
+
}
|
|
3090
|
+
else {
|
|
3091
|
+
nextSteps.push("Connect your first source with `vana connect`.");
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
else if (runtime === "missing") {
|
|
3095
|
+
nextSteps.push("Install the local runtime with `vana setup`.");
|
|
3096
|
+
nextSteps.push("Inspect install health with `vana doctor`.");
|
|
3097
|
+
}
|
|
3098
|
+
else if (runtime === "unhealthy") {
|
|
3099
|
+
nextSteps.push("Inspect install health with `vana doctor`.");
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
else if (highestPriority.dataState === "ingest_failed") {
|
|
3103
|
+
nextSteps.push(`Reconnect ${highestPriorityLabel} with \`vana connect ${highestPriority.source}\`.`);
|
|
3104
|
+
}
|
|
3105
|
+
else if (highestPriority.dataState === "ingest_unavailable") {
|
|
3106
|
+
nextSteps.push("Retry pending Personal Server sync with `vana server sync`.");
|
|
3107
|
+
}
|
|
3108
|
+
else if (highestPriority.connectionHealth === "needs_reauth" ||
|
|
3109
|
+
highestPriority.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
|
|
3110
|
+
nextSteps.push(`Reconnect ${highestPriorityLabel} with \`vana connect ${highestPriority.source}\`.`);
|
|
3111
|
+
if (highestPriority.lastLogPath) {
|
|
3112
|
+
nextSteps.push(`Inspect the latest run log with \`vana logs ${highestPriority.source}\`.`);
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
else if (highestPriority.connectionHealth === "error" ||
|
|
3116
|
+
highestPriority.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
|
|
3117
|
+
nextSteps.push(`Retry ${highestPriorityLabel} with \`vana connect ${highestPriority.source}\`.`);
|
|
3118
|
+
if (highestPriority.lastLogPath) {
|
|
3119
|
+
nextSteps.push(`Inspect the latest run log with \`vana logs ${highestPriority.source}\`.`);
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
else if (highestPriority.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
|
|
3123
|
+
nextSteps.push(`Complete the manual browser step for ${highestPriorityLabel} with \`vana connect ${highestPriority.source}\`.`);
|
|
3124
|
+
if (highestPriority.lastLogPath) {
|
|
3125
|
+
nextSteps.push(`Inspect the latest run log with \`vana logs ${highestPriority.source}\`.`);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
else if (highestPriority.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
|
|
3129
|
+
nextSteps.push("Browse available sources with `vana sources`.");
|
|
3130
|
+
if (highestPriority.lastLogPath) {
|
|
3131
|
+
nextSteps.push(`Inspect the latest run log with \`vana logs ${highestPriority.source}\`.`);
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
else if (highestPriority.dataState === "collected_local" ||
|
|
3135
|
+
highestPriority.dataState === "ingested_personal_server") {
|
|
3136
|
+
if (connectedSources.length > 1) {
|
|
3137
|
+
nextSteps.push("Review your collected data with `vana data list`.");
|
|
3138
|
+
}
|
|
3139
|
+
else {
|
|
3140
|
+
nextSteps.push(`Inspect the latest dataset with \`vana data show ${highestPriority.source}\`.`);
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
if (connectedSources.length > 0 && needsAttention) {
|
|
3144
|
+
nextSteps.push(connectedSources.length > 1
|
|
3145
|
+
? "Review the data you already collected with `vana data list`."
|
|
3146
|
+
: `Inspect the data you already collected with \`vana data show ${connectedSources[0].source}\`.`);
|
|
3147
|
+
}
|
|
3148
|
+
if (sources.some((source) => source.installed || source.lastRunOutcome) &&
|
|
3149
|
+
(!needsAttention || connectedSources.length === 0)) {
|
|
3150
|
+
nextSteps.push("Connect another source with `vana sources`.");
|
|
3151
|
+
}
|
|
3152
|
+
if (runtime !== "installed" ||
|
|
3153
|
+
sources.some((source) => source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR ||
|
|
3154
|
+
source.lastRunOutcome === CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR)) {
|
|
3155
|
+
nextSteps.push("Inspect install health with `vana doctor`.");
|
|
3156
|
+
}
|
|
3157
|
+
return [...new Set(nextSteps)];
|
|
3158
|
+
}
|
|
3159
|
+
export function buildSourcesNextSteps(recommendedSource, connectedCount) {
|
|
3160
|
+
const nextSteps = [];
|
|
3161
|
+
if (connectedCount > 0) {
|
|
3162
|
+
nextSteps.push("Inspect what you already collected with `vana data list`.");
|
|
3163
|
+
}
|
|
3164
|
+
if (recommendedSource) {
|
|
3165
|
+
nextSteps.push(`${recommendedSource.authMode === "legacy" ? "Complete" : "Connect"} ${recommendedSource.name} with \`vana connect ${recommendedSource.id}\`.`);
|
|
3166
|
+
}
|
|
3167
|
+
nextSteps.push("Or browse the guided picker with `vana connect`.");
|
|
3168
|
+
return [...new Set(nextSteps)];
|
|
3169
|
+
}
|
|
3170
|
+
export function buildDataListNextSteps(datasetRecords, registrySources) {
|
|
3171
|
+
if (datasetRecords.length === 0) {
|
|
3172
|
+
const suggestedSource = registrySources.find((source) => source.authMode !== "legacy") ??
|
|
3173
|
+
registrySources[0];
|
|
3174
|
+
return [
|
|
3175
|
+
suggestedSource
|
|
3176
|
+
? `Collect your first dataset with \`vana connect ${suggestedSource.id}\`.`
|
|
3177
|
+
: "Collect your first dataset with `vana connect`.",
|
|
3178
|
+
"Check overall status with `vana status`.",
|
|
3179
|
+
];
|
|
3180
|
+
}
|
|
3181
|
+
return [
|
|
3182
|
+
`Inspect ${datasetRecords[0].name ?? displaySource(datasetRecords[0].source)} with \`vana data show ${datasetRecords[0].source}\`.`,
|
|
3183
|
+
`Or print its path with \`vana data path ${datasetRecords[0].source}\`.`,
|
|
3184
|
+
"Connect another source with `vana sources`.",
|
|
3185
|
+
];
|
|
3186
|
+
}
|
|
3187
|
+
export function buildDataShowNextSteps(source, datasetCount, sourceLabels = {}) {
|
|
3188
|
+
return [
|
|
3189
|
+
`Print the path with \`vana data path ${source}\`.`,
|
|
3190
|
+
`Reconnect ${displaySource(source, sourceLabels)} with \`vana connect ${source}\`.`,
|
|
3191
|
+
...(datasetCount > 1
|
|
3192
|
+
? ["See all datasets with `vana data list`."]
|
|
3193
|
+
: ["Connect another source with `vana sources`."]),
|
|
3194
|
+
];
|
|
3195
|
+
}
|
|
3196
|
+
function buildLogsNextSteps(records) {
|
|
3197
|
+
if (records.length === 0) {
|
|
3198
|
+
return [
|
|
3199
|
+
"Run `vana connect <source>` to create a connector run log.",
|
|
3200
|
+
"Check overall status with `vana status`.",
|
|
3201
|
+
];
|
|
3202
|
+
}
|
|
3203
|
+
const attentionRecord = records.find((record) => isAttentionLog(record.lastRunOutcome, record.dataState));
|
|
3204
|
+
const successfulRecord = records.find((record) => !isAttentionLog(record.lastRunOutcome, record.dataState));
|
|
3205
|
+
return [
|
|
3206
|
+
attentionRecord
|
|
3207
|
+
? `Inspect the latest issue log with \`vana logs ${attentionRecord.source}\`.`
|
|
3208
|
+
: `Print the latest log path with \`vana logs ${records[0].source}\`.`,
|
|
3209
|
+
...(successfulRecord
|
|
3210
|
+
? [
|
|
3211
|
+
`Inspect a successful run with \`vana logs ${successfulRecord.source}\`.`,
|
|
3212
|
+
]
|
|
3213
|
+
: []),
|
|
3214
|
+
"Check overall status with `vana status`.",
|
|
3215
|
+
];
|
|
3216
|
+
}
|
|
3217
|
+
/** Derive a human-readable message from a stored `connectionHealthReason`. */
|
|
3218
|
+
export function formatHealthMessage(reason) {
|
|
3219
|
+
if (!reason)
|
|
3220
|
+
return null;
|
|
3221
|
+
const colonIndex = reason.indexOf(": ");
|
|
3222
|
+
const prefix = colonIndex > 0 ? reason.slice(0, colonIndex) : reason;
|
|
3223
|
+
const detail = colonIndex > 0 ? reason.slice(colonIndex + 2).replace(/\.$/, "") : "";
|
|
3224
|
+
switch (prefix) {
|
|
3225
|
+
case "needs-input":
|
|
3226
|
+
return `Requires interactive login${detail ? `: ${detail}` : ""}.`;
|
|
3227
|
+
case "legacy-auth":
|
|
3228
|
+
return `Needed a browser window${detail ? `: ${detail}` : ""}. Reconnect interactively.`;
|
|
3229
|
+
case "runtime-error":
|
|
3230
|
+
return `Collection failed${detail ? ` — ${detail}` : ""}.`;
|
|
3231
|
+
case "error-result":
|
|
3232
|
+
return `Connector returned an error${detail ? `: ${detail}` : ""}.`;
|
|
3233
|
+
case "collection-complete":
|
|
3234
|
+
return null; // No message needed for healthy state
|
|
3235
|
+
default:
|
|
3236
|
+
return reason; // Graceful fallback for unknown prefixes
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
/** Extract a `vana ...` command from a next-step sentence wrapped in backticks. */
|
|
3240
|
+
function extractCommand(sentence) {
|
|
3241
|
+
const match = sentence.match(/`(vana\s[^`]+)`/);
|
|
3242
|
+
return match ? match[1] : null;
|
|
3243
|
+
}
|
|
3244
|
+
// describeConnectTrust and buildConnectChoices removed — replaced by clack-based picker
|
|
3245
|
+
function formatMissingConnectSourceMessage(source) {
|
|
3246
|
+
if (source) {
|
|
3247
|
+
return `Specify a source. Start with \`vana connect ${source.id}\`, or run \`vana sources\` to see available options.`;
|
|
3248
|
+
}
|
|
3249
|
+
return "Specify a source. Run `vana sources` to see available options.";
|
|
3250
|
+
}
|
|
3251
|
+
// formatSourcePickerDescription removed — replaced by clack-based picker with hints
|
|
3252
|
+
function normalizeArgv(argv) {
|
|
3253
|
+
if (argv[2] === "connect" &&
|
|
3254
|
+
["list", "status", "setup"].includes(argv[3] ?? "")) {
|
|
3255
|
+
const mapping = {
|
|
3256
|
+
list: "sources",
|
|
3257
|
+
status: "status",
|
|
3258
|
+
setup: "setup",
|
|
3259
|
+
};
|
|
3260
|
+
return [argv[0], argv[1], mapping[argv[3]], ...argv.slice(4)];
|
|
3261
|
+
}
|
|
3262
|
+
return argv;
|
|
3263
|
+
}
|
|
3264
|
+
export function getCliVersion() {
|
|
3265
|
+
if (process.env.VANA_APP_ROOT) {
|
|
3266
|
+
try {
|
|
3267
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(process.env.VANA_APP_ROOT, "package.json"), "utf8"));
|
|
3268
|
+
if (packageJson.version) {
|
|
3269
|
+
return packageJson.version;
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
catch {
|
|
3273
|
+
// Fall through to the repo/dev package metadata.
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
try {
|
|
3277
|
+
const packageJson = require("../../package.json");
|
|
3278
|
+
if (packageJson.version) {
|
|
3279
|
+
return packageJson.version;
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
catch {
|
|
3283
|
+
// Fall through to the hard default.
|
|
3284
|
+
}
|
|
3285
|
+
return "0.0.0";
|
|
3286
|
+
}
|
|
3287
|
+
export function getCliChannel(version = getCliVersion()) {
|
|
3288
|
+
if (version.includes("canary")) {
|
|
3289
|
+
return "canary";
|
|
3290
|
+
}
|
|
3291
|
+
const candidates = [process.env.VANA_APP_ROOT ?? "", process.execPath].map((value) => value.replace(/\\/g, "/").toLowerCase());
|
|
3292
|
+
return candidates.some((normalizedPath) => /\/releases\/canary-[^/]+(?:\/app)?$/.test(normalizedPath))
|
|
3293
|
+
? "canary"
|
|
3294
|
+
: "stable";
|
|
3295
|
+
}
|
|
3296
|
+
export function getCliInstallMethod(execPath = process.execPath) {
|
|
3297
|
+
const candidates = [process.env.VANA_APP_ROOT ?? "", execPath].map((value) => value.replace(/\\/g, "/").toLowerCase());
|
|
3298
|
+
for (const normalizedPath of candidates) {
|
|
3299
|
+
if (!normalizedPath) {
|
|
3300
|
+
continue;
|
|
3301
|
+
}
|
|
3302
|
+
if (normalizedPath.includes("/cellar/vana/")) {
|
|
3303
|
+
return "homebrew";
|
|
3304
|
+
}
|
|
3305
|
+
if (normalizedPath.includes("/.local/share/vana/") ||
|
|
3306
|
+
normalizedPath.includes("/appdata/local/vana/") ||
|
|
3307
|
+
normalizedPath.endsWith("/current/app") ||
|
|
3308
|
+
/\/releases\/[^/]+\/app$/.test(normalizedPath)) {
|
|
3309
|
+
return "installer";
|
|
3310
|
+
}
|
|
3311
|
+
if (normalizedPath.endsWith("/node") ||
|
|
3312
|
+
normalizedPath.endsWith("/node.exe") ||
|
|
3313
|
+
normalizedPath.includes("/.nvm/") ||
|
|
3314
|
+
normalizedPath.includes("/volta/") ||
|
|
3315
|
+
normalizedPath.includes("/pnpm/")) {
|
|
3316
|
+
return "development";
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
return "unknown";
|
|
3320
|
+
}
|
|
3321
|
+
function getCliAppRoot(execPath = process.execPath) {
|
|
3322
|
+
return process.env.VANA_APP_ROOT ?? path.join(path.dirname(execPath), "app");
|
|
3323
|
+
}
|
|
3324
|
+
export function getDoctorAppRootPath(installMethod, execPath = process.execPath) {
|
|
3325
|
+
if (process.env.VANA_APP_ROOT) {
|
|
3326
|
+
return process.env.VANA_APP_ROOT;
|
|
3327
|
+
}
|
|
3328
|
+
if (installMethod === "homebrew" || installMethod === "installer") {
|
|
3329
|
+
return getCliAppRoot(execPath);
|
|
3330
|
+
}
|
|
3331
|
+
return null;
|
|
3332
|
+
}
|
|
3333
|
+
export function formatInstallMethodLabel(method) {
|
|
3334
|
+
switch (method) {
|
|
3335
|
+
case "homebrew":
|
|
3336
|
+
return "Homebrew";
|
|
3337
|
+
case "installer":
|
|
3338
|
+
return "Hosted installer";
|
|
3339
|
+
case "development":
|
|
3340
|
+
return "Development checkout";
|
|
3341
|
+
default:
|
|
3342
|
+
return "Unknown";
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
export function getLifecycleCommands(installMethod, channel) {
|
|
3346
|
+
switch (installMethod) {
|
|
3347
|
+
case "homebrew":
|
|
3348
|
+
return {
|
|
3349
|
+
upgrade: "brew update && brew upgrade vana",
|
|
3350
|
+
uninstall: "brew uninstall vana",
|
|
3351
|
+
};
|
|
3352
|
+
case "installer":
|
|
3353
|
+
if (process.platform === "win32") {
|
|
3354
|
+
return {
|
|
3355
|
+
upgrade: 'powershell -Command "irm https://raw.githubusercontent.com/vana-com/vana-cli/main/install/install.ps1 | iex"',
|
|
3356
|
+
uninstall: 'powershell -Command "Remove-Item $env:LOCALAPPDATA\\Vana -Recurse -Force; Remove-Item $env:USERPROFILE\\.vana -Recurse -Force"',
|
|
3357
|
+
};
|
|
3358
|
+
}
|
|
3359
|
+
return {
|
|
3360
|
+
upgrade: channel === "canary"
|
|
3361
|
+
? "curl -fsSL https://raw.githubusercontent.com/vana-com/vana-cli/feat/connect-cli-v1/install/install.sh | sh -s -- --version canary-feat-connect-cli-v1"
|
|
3362
|
+
: "curl -fsSL https://raw.githubusercontent.com/vana-com/vana-cli/main/install/install.sh | sh",
|
|
3363
|
+
uninstall: "rm -f ~/.local/bin/vana && rm -rf ~/.local/share/vana ~/.vana",
|
|
3364
|
+
};
|
|
3365
|
+
case "development":
|
|
3366
|
+
return {
|
|
3367
|
+
upgrade: "git pull && pnpm install && pnpm build",
|
|
3368
|
+
uninstall: "Remove the local checkout and any generated ~/.vana state.",
|
|
3369
|
+
};
|
|
3370
|
+
default:
|
|
3371
|
+
return {
|
|
3372
|
+
upgrade: "Reinstall vana using Homebrew or the hosted installer.",
|
|
3373
|
+
uninstall: "Remove the installed vana binary and any ~/.vana state you no longer need.",
|
|
3374
|
+
};
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
function extractGlobalOptions(argv) {
|
|
3378
|
+
return {
|
|
3379
|
+
json: argv.includes("--json"),
|
|
3380
|
+
noInput: argv.includes("--no-input"),
|
|
3381
|
+
ipc: argv.includes("--ipc"),
|
|
3382
|
+
yes: argv.includes("--yes"),
|
|
3383
|
+
quiet: argv.includes("--quiet"),
|
|
3384
|
+
detach: argv.includes("--detach"),
|
|
3385
|
+
};
|
|
3386
|
+
}
|
|
3387
|
+
export function createSourceLabelMap(sources) {
|
|
3388
|
+
return Object.fromEntries(sources.map((source) => [source.id, source.name]));
|
|
3389
|
+
}
|
|
3390
|
+
export function createSourceMetadataMap(sources) {
|
|
3391
|
+
return Object.fromEntries(sources.map((source) => [
|
|
3392
|
+
source.id,
|
|
3393
|
+
{
|
|
3394
|
+
name: source.name,
|
|
3395
|
+
company: source.company,
|
|
3396
|
+
description: source.description,
|
|
3397
|
+
authMode: source.authMode,
|
|
3398
|
+
},
|
|
3399
|
+
]));
|
|
3400
|
+
}
|
|
3401
|
+
// formatAuthModeBadge removed — replaced by clack-based picker with hints
|
|
3402
|
+
export function getSourceStatusPresentation(source) {
|
|
3403
|
+
if (!source.installed && !source.lastRunOutcome) {
|
|
3404
|
+
return { label: "not connected", tone: "muted" };
|
|
3405
|
+
}
|
|
3406
|
+
// Check dataState before early-returning on missing lastRunOutcome —
|
|
3407
|
+
// ingest can fail even when lastRunOutcome is unset
|
|
3408
|
+
if (source.dataState === "ingest_failed") {
|
|
3409
|
+
return { label: "sync failed", tone: "error" };
|
|
3410
|
+
}
|
|
3411
|
+
if (!source.lastRunOutcome) {
|
|
3412
|
+
return { label: "installed", tone: "success" };
|
|
3413
|
+
}
|
|
3414
|
+
if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
|
|
3415
|
+
return { label: "needs input", tone: "warning" };
|
|
3416
|
+
}
|
|
3417
|
+
if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
|
|
3418
|
+
return { label: "error", tone: "error" };
|
|
3419
|
+
}
|
|
3420
|
+
if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
|
|
3421
|
+
return { label: "unavailable", tone: "warning" };
|
|
3422
|
+
}
|
|
3423
|
+
if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
|
|
3424
|
+
return { label: "manual step", tone: "warning" };
|
|
3425
|
+
}
|
|
3426
|
+
if (source.dataState === "ingested_personal_server") {
|
|
3427
|
+
// Check per-scope state for more granular badges
|
|
3428
|
+
if (source.ingestScopes && source.ingestScopes.length > 0) {
|
|
3429
|
+
const storedCount = source.ingestScopes.filter((s) => s.status === "stored").length;
|
|
3430
|
+
const failedCount = source.ingestScopes.filter((s) => s.status === "failed").length;
|
|
3431
|
+
if (failedCount > 0 && storedCount > 0) {
|
|
3432
|
+
return { label: "partial sync", tone: "warning" };
|
|
3433
|
+
}
|
|
3434
|
+
if (failedCount > 0 && storedCount === 0) {
|
|
3435
|
+
return { label: "sync failed", tone: "error" };
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
return { label: "synced", tone: "success" };
|
|
3439
|
+
}
|
|
3440
|
+
if (source.dataState === "ingest_unavailable") {
|
|
3441
|
+
return { label: "pending sync", tone: "warning" };
|
|
3442
|
+
}
|
|
3443
|
+
if (source.dataState === "collected_local") {
|
|
3444
|
+
return { label: "local", tone: "muted" };
|
|
3445
|
+
}
|
|
3446
|
+
return { label: "connected", tone: "success" };
|
|
3447
|
+
}
|
|
3448
|
+
export function toneForRuntime(runtime) {
|
|
3449
|
+
if (runtime === "installed") {
|
|
3450
|
+
return "success";
|
|
3451
|
+
}
|
|
3452
|
+
if (runtime === "missing") {
|
|
3453
|
+
return "warning";
|
|
3454
|
+
}
|
|
3455
|
+
return "muted";
|
|
3456
|
+
}
|
|
3457
|
+
// formatProgressUpdate removed — replaced by ConnectRenderer scope methods
|
|
3458
|
+
/**
|
|
3459
|
+
* Extract a human-readable scope name from a progress-update event.
|
|
3460
|
+
* The scope name comes from `phase.label` when `phase` is a structured object.
|
|
3461
|
+
*/
|
|
3462
|
+
function extractScopeName(event) {
|
|
3463
|
+
if (event.phase &&
|
|
3464
|
+
typeof event.phase === "object" &&
|
|
3465
|
+
"label" in event.phase &&
|
|
3466
|
+
typeof event.phase.label === "string") {
|
|
3467
|
+
return event.phase.label;
|
|
3468
|
+
}
|
|
3469
|
+
return null;
|
|
3470
|
+
}
|
|
3471
|
+
/**
|
|
3472
|
+
* Format detail text for a completed scope (e.g. "8 found").
|
|
3473
|
+
* Extracts count from event.count or parses it from the message.
|
|
3474
|
+
*/
|
|
3475
|
+
function formatScopeDetail(event) {
|
|
3476
|
+
if (typeof event.count === "number") {
|
|
3477
|
+
return `${event.count} found`;
|
|
3478
|
+
}
|
|
3479
|
+
// Try to extract a count from the completion message (e.g. "Complete! 8 repositories collected.")
|
|
3480
|
+
if (typeof event.message === "string") {
|
|
3481
|
+
const match = event.message.match(/(\d+)\s+\w+/);
|
|
3482
|
+
if (match) {
|
|
3483
|
+
return match[0];
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
return undefined;
|
|
3487
|
+
}
|
|
3488
|
+
// shouldRenderStatusUpdate removed — status updates are silent in the new design
|
|
3489
|
+
function inferInstalledAuthMode(installedFiles, source) {
|
|
3490
|
+
const match = installedFiles.find((file) => file.source === source);
|
|
3491
|
+
if (!match) {
|
|
3492
|
+
return undefined;
|
|
3493
|
+
}
|
|
3494
|
+
try {
|
|
3495
|
+
const script = fs.readFileSync(match.path, "utf8");
|
|
3496
|
+
if (/page\.requestInput\(/.test(script)) {
|
|
3497
|
+
return "interactive";
|
|
3498
|
+
}
|
|
3499
|
+
if (/page\.(showBrowser|promptUser)\(/.test(script)) {
|
|
3500
|
+
return "legacy";
|
|
3501
|
+
}
|
|
3502
|
+
return "automated";
|
|
3503
|
+
}
|
|
3504
|
+
catch {
|
|
3505
|
+
return undefined;
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
export async function loadRegistrySources() {
|
|
3509
|
+
try {
|
|
3510
|
+
return ((await listAvailableSources(findDataConnectorsDir() ?? undefined)) ?? []).sort(compareRegistrySourceOrder);
|
|
3511
|
+
}
|
|
3512
|
+
catch {
|
|
3513
|
+
return [];
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
function compareRegistrySourceOrder(left, right) {
|
|
3517
|
+
return (rankAuthMode(left.authMode) - rankAuthMode(right.authMode) ||
|
|
3518
|
+
left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
|
|
3519
|
+
}
|
|
3520
|
+
export function compareSourceStatusOrder(left, right) {
|
|
3521
|
+
return (rankSourceStatus(left) - rankSourceStatus(right) ||
|
|
3522
|
+
compareRegistrySourceOrder({
|
|
3523
|
+
id: left.source,
|
|
3524
|
+
name: left.name ?? displaySource(left.source),
|
|
3525
|
+
authMode: left.authMode,
|
|
3526
|
+
}, {
|
|
3527
|
+
id: right.source,
|
|
3528
|
+
name: right.name ?? displaySource(right.source),
|
|
3529
|
+
authMode: right.authMode,
|
|
3530
|
+
}));
|
|
3531
|
+
}
|
|
3532
|
+
export function isSourceAttention(source) {
|
|
3533
|
+
if (source.dataState === "ingest_failed" ||
|
|
3534
|
+
source.dataState === "ingest_unavailable") {
|
|
3535
|
+
return true;
|
|
3536
|
+
}
|
|
3537
|
+
if (source.connectionHealth === "needs_reauth" ||
|
|
3538
|
+
source.connectionHealth === "error" ||
|
|
3539
|
+
source.connectionHealth === "stale") {
|
|
3540
|
+
return true;
|
|
3541
|
+
}
|
|
3542
|
+
return rankSourceStatus(source) <= 4;
|
|
3543
|
+
}
|
|
3544
|
+
function compareAttentionPriority(left, right) {
|
|
3545
|
+
const rank = (source) => {
|
|
3546
|
+
if (source.dataState === "ingest_failed") {
|
|
3547
|
+
return 0;
|
|
3548
|
+
}
|
|
3549
|
+
if (source.dataState === "ingest_unavailable") {
|
|
3550
|
+
return 1;
|
|
3551
|
+
}
|
|
3552
|
+
if (source.connectionHealth === "needs_reauth") {
|
|
3553
|
+
return 2;
|
|
3554
|
+
}
|
|
3555
|
+
if (source.connectionHealth === "error") {
|
|
3556
|
+
return 3;
|
|
3557
|
+
}
|
|
3558
|
+
if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
|
|
3559
|
+
return 4;
|
|
3560
|
+
}
|
|
3561
|
+
if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
|
|
3562
|
+
return 5;
|
|
3563
|
+
}
|
|
3564
|
+
if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
|
|
3565
|
+
return 6;
|
|
3566
|
+
}
|
|
3567
|
+
if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
|
|
3568
|
+
return 7;
|
|
3569
|
+
}
|
|
3570
|
+
return 8;
|
|
3571
|
+
};
|
|
3572
|
+
return rank(left) - rank(right) || compareSourceStatusOrder(left, right);
|
|
3573
|
+
}
|
|
3574
|
+
export function rankSourceStatus(source) {
|
|
3575
|
+
if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
|
|
3576
|
+
return 0;
|
|
3577
|
+
}
|
|
3578
|
+
if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
|
|
3579
|
+
return 1;
|
|
3580
|
+
}
|
|
3581
|
+
if (source.lastRunOutcome === CliOutcomeStatus.INGEST_FAILED) {
|
|
3582
|
+
return 2;
|
|
3583
|
+
}
|
|
3584
|
+
if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
|
|
3585
|
+
return 3;
|
|
3586
|
+
}
|
|
3587
|
+
if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
|
|
3588
|
+
return 4;
|
|
3589
|
+
}
|
|
3590
|
+
if (source.dataState === "ingest_failed") {
|
|
3591
|
+
return 2;
|
|
3592
|
+
}
|
|
3593
|
+
if (source.dataState === "ingested_personal_server") {
|
|
3594
|
+
return 5;
|
|
3595
|
+
}
|
|
3596
|
+
if (source.dataState === "collected_local") {
|
|
3597
|
+
return 6;
|
|
3598
|
+
}
|
|
3599
|
+
if (source.installed) {
|
|
3600
|
+
return 7;
|
|
3601
|
+
}
|
|
3602
|
+
return 8;
|
|
3603
|
+
}
|
|
3604
|
+
function rankAuthMode(authMode) {
|
|
3605
|
+
if (authMode === "interactive") {
|
|
3606
|
+
return 0;
|
|
3607
|
+
}
|
|
3608
|
+
if (authMode === "automated") {
|
|
3609
|
+
return 1;
|
|
3610
|
+
}
|
|
3611
|
+
if (authMode === "legacy") {
|
|
3612
|
+
return 2;
|
|
3613
|
+
}
|
|
3614
|
+
return 3;
|
|
3615
|
+
}
|
|
3616
|
+
export async function readResultSummary(resultPath) {
|
|
3617
|
+
try {
|
|
3618
|
+
const raw = await fsp.readFile(resultPath, "utf8");
|
|
3619
|
+
return summarizeResultData(JSON.parse(raw));
|
|
3620
|
+
}
|
|
3621
|
+
catch {
|
|
3622
|
+
return null;
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
export function summarizeResultData(data) {
|
|
3626
|
+
const lines = [];
|
|
3627
|
+
const exportSummary = typeof data.exportSummary === "object" && data.exportSummary
|
|
3628
|
+
? data.exportSummary
|
|
3629
|
+
: null;
|
|
3630
|
+
const profile = typeof data.profile === "object" && data.profile
|
|
3631
|
+
? data.profile
|
|
3632
|
+
: null;
|
|
3633
|
+
if (profile?.username && typeof profile.username === "string") {
|
|
3634
|
+
lines.push(`Profile: ${profile.username}`);
|
|
3635
|
+
}
|
|
3636
|
+
if (Array.isArray(data.repositories)) {
|
|
3637
|
+
lines.push(`Repositories: ${data.repositories.length}`);
|
|
3638
|
+
const preview = summarizeNamedItems(data.repositories, "Latest repos");
|
|
3639
|
+
if (preview) {
|
|
3640
|
+
lines.push(preview);
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
if (Array.isArray(data.starred)) {
|
|
3644
|
+
lines.push(`Starred: ${data.starred.length}`);
|
|
3645
|
+
}
|
|
3646
|
+
if (Array.isArray(data.orders)) {
|
|
3647
|
+
lines.push(`Orders: ${data.orders.length}`);
|
|
3648
|
+
}
|
|
3649
|
+
if (Array.isArray(data.playlists)) {
|
|
3650
|
+
lines.push(`Playlists: ${data.playlists.length}`);
|
|
3651
|
+
const preview = summarizeNamedItems(data.playlists, "Playlists");
|
|
3652
|
+
if (preview) {
|
|
3653
|
+
lines.push(preview);
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
if (exportSummary?.details &&
|
|
3657
|
+
typeof exportSummary.details === "string" &&
|
|
3658
|
+
!lines.includes(exportSummary.details) &&
|
|
3659
|
+
!Array.isArray(data.repositories) &&
|
|
3660
|
+
!Array.isArray(data.starred) &&
|
|
3661
|
+
!Array.isArray(data.orders) &&
|
|
3662
|
+
!Array.isArray(data.playlists)) {
|
|
3663
|
+
lines.push(exportSummary.details);
|
|
3664
|
+
}
|
|
3665
|
+
return lines.length > 0 ? { lines } : null;
|
|
3666
|
+
}
|
|
3667
|
+
function summarizeNamedItems(items, label, maxItems = 2) {
|
|
3668
|
+
const names = items
|
|
3669
|
+
.map((item) => {
|
|
3670
|
+
if (typeof item === "object" &&
|
|
3671
|
+
item &&
|
|
3672
|
+
"name" in item &&
|
|
3673
|
+
typeof item.name === "string") {
|
|
3674
|
+
return item.name;
|
|
3675
|
+
}
|
|
3676
|
+
return null;
|
|
3677
|
+
})
|
|
3678
|
+
.filter((value) => Boolean(value))
|
|
3679
|
+
.slice(0, maxItems);
|
|
3680
|
+
if (names.length === 0) {
|
|
3681
|
+
return null;
|
|
3682
|
+
}
|
|
3683
|
+
return `${label}: ${names.join(", ")}`;
|
|
3684
|
+
}
|
|
3685
|
+
export function formatTimestamp(value) {
|
|
3686
|
+
const date = new Date(value);
|
|
3687
|
+
if (Number.isNaN(date.getTime())) {
|
|
3688
|
+
return value;
|
|
3689
|
+
}
|
|
3690
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
3691
|
+
dateStyle: "medium",
|
|
3692
|
+
timeStyle: "short",
|
|
3693
|
+
}).format(date);
|
|
3694
|
+
}
|
|
3695
|
+
export function compareDatasetOrder(left, right) {
|
|
3696
|
+
const leftTime = left.lastRunAt ? Date.parse(left.lastRunAt) : 0;
|
|
3697
|
+
const rightTime = right.lastRunAt ? Date.parse(right.lastRunAt) : 0;
|
|
3698
|
+
return (rightTime - leftTime ||
|
|
3699
|
+
(left.name ?? left.source).localeCompare(right.name ?? right.source, undefined, {
|
|
3700
|
+
sensitivity: "base",
|
|
3701
|
+
}));
|
|
3702
|
+
}
|
|
3703
|
+
function compareLogRecordOrder(left, right) {
|
|
3704
|
+
const leftTimestamp = left.lastRunAt ? Date.parse(left.lastRunAt) : 0;
|
|
3705
|
+
const rightTimestamp = right.lastRunAt ? Date.parse(right.lastRunAt) : 0;
|
|
3706
|
+
return (rightTimestamp - leftTimestamp ||
|
|
3707
|
+
left.source.localeCompare(right.source, undefined, {
|
|
3708
|
+
sensitivity: "base",
|
|
3709
|
+
}));
|
|
3710
|
+
}
|
|
3711
|
+
export function hasCollectedData(dataState) {
|
|
3712
|
+
return (dataState === "collected_local" ||
|
|
3713
|
+
dataState === "ingest_unavailable" ||
|
|
3714
|
+
dataState === "ingested_personal_server" ||
|
|
3715
|
+
dataState === "ingest_failed");
|
|
3716
|
+
}
|
|
3717
|
+
function formatLogOutcomeLabel(lastRunOutcome, dataState) {
|
|
3718
|
+
if (lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
|
|
3719
|
+
return "unavailable";
|
|
3720
|
+
}
|
|
3721
|
+
if (lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
|
|
3722
|
+
return "manual step";
|
|
3723
|
+
}
|
|
3724
|
+
if (lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
|
|
3725
|
+
return "error";
|
|
3726
|
+
}
|
|
3727
|
+
if (lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
|
|
3728
|
+
return "needs input";
|
|
3729
|
+
}
|
|
3730
|
+
if (dataState === "ingested_personal_server") {
|
|
3731
|
+
return "synced";
|
|
3732
|
+
}
|
|
3733
|
+
if (dataState === "ingest_unavailable") {
|
|
3734
|
+
return "pending sync";
|
|
3735
|
+
}
|
|
3736
|
+
if (dataState === "ingest_failed") {
|
|
3737
|
+
return "sync failed";
|
|
3738
|
+
}
|
|
3739
|
+
if (dataState === "collected_local") {
|
|
3740
|
+
return "local";
|
|
3741
|
+
}
|
|
3742
|
+
return "recent";
|
|
3743
|
+
}
|
|
3744
|
+
function isAttentionLog(lastRunOutcome, dataState) {
|
|
3745
|
+
return !(dataState === "collected_local" ||
|
|
3746
|
+
dataState === "ingested_personal_server" ||
|
|
3747
|
+
lastRunOutcome === CliOutcomeStatus.CONNECTED_LOCAL_ONLY ||
|
|
3748
|
+
lastRunOutcome === CliOutcomeStatus.CONNECTED_AND_INGESTED);
|
|
3749
|
+
}
|
|
3750
|
+
function toneForLogOutcome(lastRunOutcome, dataState) {
|
|
3751
|
+
if (lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
|
|
3752
|
+
return "error";
|
|
3753
|
+
}
|
|
3754
|
+
if (lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE ||
|
|
3755
|
+
lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH ||
|
|
3756
|
+
lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT ||
|
|
3757
|
+
dataState === "ingest_failed" ||
|
|
3758
|
+
dataState === "ingest_unavailable") {
|
|
3759
|
+
return "warning";
|
|
3760
|
+
}
|
|
3761
|
+
if (dataState === "ingested_personal_server") {
|
|
3762
|
+
return "success";
|
|
3763
|
+
}
|
|
3764
|
+
if (dataState === "collected_local") {
|
|
3765
|
+
return "muted";
|
|
3766
|
+
}
|
|
3767
|
+
return "muted";
|
|
3768
|
+
}
|
|
3769
|
+
// ---------------------------------------------------------------------------
|
|
3770
|
+
// Detach (background process)
|
|
3771
|
+
// ---------------------------------------------------------------------------
|
|
3772
|
+
async function runDetached(command, source, options) {
|
|
3773
|
+
const emit = createEmitter(options);
|
|
3774
|
+
const registrySources = await loadRegistrySources();
|
|
3775
|
+
const sourceLabels = createSourceLabelMap(registrySources);
|
|
3776
|
+
const displayName = displaySource(source, sourceLabels);
|
|
3777
|
+
// Check if source has been previously connected (has a session to reuse).
|
|
3778
|
+
// Detach is for re-collection with existing sessions, not first-time auth.
|
|
3779
|
+
const state = await readCliState();
|
|
3780
|
+
const sourceState = state.sources[source];
|
|
3781
|
+
if (!sourceState?.lastResultPath && !sourceState?.sessionPresent) {
|
|
3782
|
+
emit.info(`Run ${emit.code(`vana connect ${source}`)} first to authenticate.`);
|
|
3783
|
+
emit.detail("Use --detach for background re-collection after the first connect.");
|
|
3784
|
+
return 1;
|
|
3785
|
+
}
|
|
3786
|
+
const sessionsDir = getSessionsDir();
|
|
3787
|
+
const logsDir = getLogsDir();
|
|
3788
|
+
await fsp.mkdir(sessionsDir, { recursive: true });
|
|
3789
|
+
await fsp.mkdir(logsDir, { recursive: true });
|
|
3790
|
+
const logPath = path.join(logsDir, `${source}-detach.log`);
|
|
3791
|
+
const sessionPath = path.join(sessionsDir, `${source}.json`);
|
|
3792
|
+
const logFd = fs.openSync(logPath, "a");
|
|
3793
|
+
// --no-input: if auth is needed, fail fast and record needs_reauth.
|
|
3794
|
+
// Don't use --ipc: nobody is watching a detached process.
|
|
3795
|
+
const childArgs = [
|
|
3796
|
+
process.argv[1],
|
|
3797
|
+
command,
|
|
3798
|
+
source,
|
|
3799
|
+
"--json",
|
|
3800
|
+
"--quiet",
|
|
3801
|
+
"--no-input",
|
|
3802
|
+
];
|
|
3803
|
+
const child = spawn(process.execPath, childArgs, {
|
|
3804
|
+
detached: true,
|
|
3805
|
+
stdio: ["ignore", logFd, logFd],
|
|
3806
|
+
env: { ...process.env, VANA_DETACHED: "1" },
|
|
3807
|
+
});
|
|
3808
|
+
trackActiveTelemetryEvent("detached_run_spawned", { source });
|
|
3809
|
+
child.unref();
|
|
3810
|
+
fs.closeSync(logFd);
|
|
3811
|
+
// Write session file
|
|
3812
|
+
const session = {
|
|
3813
|
+
source,
|
|
3814
|
+
command,
|
|
3815
|
+
pid: child.pid,
|
|
3816
|
+
startedAt: new Date().toISOString(),
|
|
3817
|
+
status: "running",
|
|
3818
|
+
logPath,
|
|
3819
|
+
};
|
|
3820
|
+
await fsp.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`);
|
|
3821
|
+
if (options.json) {
|
|
3822
|
+
process.stdout.write(`${JSON.stringify(session)}\n`);
|
|
3823
|
+
return 0;
|
|
3824
|
+
}
|
|
3825
|
+
const verb = command === "connect" ? "Connecting" : "Collecting";
|
|
3826
|
+
emit.info(`${verb} ${displayName} in the background.`);
|
|
3827
|
+
emit.detail(`Check progress: ${emit.code("vana status")}`);
|
|
3828
|
+
return 0;
|
|
3829
|
+
}
|
|
3830
|
+
// ---------------------------------------------------------------------------
|
|
3831
|
+
// Schedule commands
|
|
3832
|
+
// ---------------------------------------------------------------------------
|
|
3833
|
+
const LAUNCHD_LABEL = "com.vana.collect";
|
|
3834
|
+
const LAUNCHD_PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
3835
|
+
const CRONTAB_MARKER = "# vana-scheduled-collection";
|
|
3836
|
+
const WINDOWS_TASK_NAME = "VanaScheduledCollection";
|
|
3837
|
+
function parseIntervalSeconds(interval) {
|
|
3838
|
+
const lower = interval.toLowerCase().trim();
|
|
3839
|
+
const match = /^(\d+)\s*(h|d|m|w)$/i.exec(lower);
|
|
3840
|
+
if (match) {
|
|
3841
|
+
const value = parseInt(match[1], 10);
|
|
3842
|
+
const unit = match[2].toLowerCase();
|
|
3843
|
+
if (unit === "h")
|
|
3844
|
+
return value * 3600;
|
|
3845
|
+
if (unit === "d")
|
|
3846
|
+
return value * 86400;
|
|
3847
|
+
if (unit === "w")
|
|
3848
|
+
return value * 7 * 86400;
|
|
3849
|
+
if (unit === "m")
|
|
3850
|
+
return value * 30 * 86400;
|
|
3851
|
+
}
|
|
3852
|
+
if (lower === "daily")
|
|
3853
|
+
return 86400;
|
|
3854
|
+
if (lower === "weekly")
|
|
3855
|
+
return 7 * 86400;
|
|
3856
|
+
// Default to 24h
|
|
3857
|
+
return 86400;
|
|
3858
|
+
}
|
|
3859
|
+
function formatIntervalHuman(seconds) {
|
|
3860
|
+
if (seconds < 3600)
|
|
3861
|
+
return `${Math.round(seconds / 60)}m`;
|
|
3862
|
+
if (seconds < 86400)
|
|
3863
|
+
return `${Math.round(seconds / 3600)}h`;
|
|
3864
|
+
return `${Math.round(seconds / 86400)}d`;
|
|
3865
|
+
}
|
|
3866
|
+
function resolveVanaBinaryPath() {
|
|
3867
|
+
// For SEA binaries, process.execPath is the binary itself
|
|
3868
|
+
const installMethod = getCliInstallMethod();
|
|
3869
|
+
if (installMethod === "homebrew" || installMethod === "installer") {
|
|
3870
|
+
return process.execPath;
|
|
3871
|
+
}
|
|
3872
|
+
// For development, try to find the vana binary via which/where
|
|
3873
|
+
try {
|
|
3874
|
+
const cmd = process.platform === "win32" ? "where vana" : "which vana";
|
|
3875
|
+
const result = execSync(cmd, { encoding: "utf8" }).trim();
|
|
3876
|
+
// `where` on Windows may return multiple lines; take the first
|
|
3877
|
+
return result.split("\n")[0].trim();
|
|
3878
|
+
}
|
|
3879
|
+
catch {
|
|
3880
|
+
// Fall back to process.execPath + argv[1]
|
|
3881
|
+
return `${process.execPath} ${process.argv[1]}`;
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
async function getExistingScheduleInterval() {
|
|
3885
|
+
if (process.platform === "darwin") {
|
|
3886
|
+
try {
|
|
3887
|
+
const content = await fsp.readFile(LAUNCHD_PLIST_PATH, "utf8");
|
|
3888
|
+
const match = content.match(/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/);
|
|
3889
|
+
return match ? parseInt(match[1], 10) : null;
|
|
3890
|
+
}
|
|
3891
|
+
catch {
|
|
3892
|
+
return null;
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
if (process.platform === "linux") {
|
|
3896
|
+
try {
|
|
3897
|
+
const crontab = execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
|
|
3898
|
+
const vanaLine = crontab
|
|
3899
|
+
.split("\n")
|
|
3900
|
+
.find((line) => line.includes(CRONTAB_MARKER));
|
|
3901
|
+
if (!vanaLine)
|
|
3902
|
+
return null;
|
|
3903
|
+
// Parse hour interval from crontab: look for */N pattern
|
|
3904
|
+
const hourMatch = /\*\/(\d+)/.exec(vanaLine);
|
|
3905
|
+
if (hourMatch)
|
|
3906
|
+
return parseInt(hourMatch[1], 10) * 3600;
|
|
3907
|
+
// Daily (0 0 * * *)
|
|
3908
|
+
return 86400;
|
|
3909
|
+
}
|
|
3910
|
+
catch {
|
|
3911
|
+
return null;
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
if (process.platform === "win32") {
|
|
3915
|
+
try {
|
|
3916
|
+
execSync(`schtasks /Query /TN "${WINDOWS_TASK_NAME}" /FO LIST`, {
|
|
3917
|
+
encoding: "utf8",
|
|
3918
|
+
});
|
|
3919
|
+
// Task exists but we can't easily parse the interval; return a
|
|
3920
|
+
// sentinel value (86400) to indicate "schedule present".
|
|
3921
|
+
return 86400;
|
|
3922
|
+
}
|
|
3923
|
+
catch {
|
|
3924
|
+
return null;
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
return null;
|
|
3928
|
+
}
|
|
3929
|
+
async function maybeAutoSchedule(emit, options) {
|
|
3930
|
+
// Skip if --no-input (detached/agent context shouldn't create schedules)
|
|
3931
|
+
if (options.noInput)
|
|
3932
|
+
return;
|
|
3933
|
+
// Skip unsupported platforms
|
|
3934
|
+
if (!["darwin", "linux", "win32"].includes(process.platform))
|
|
3935
|
+
return;
|
|
3936
|
+
const existing = await getExistingScheduleInterval();
|
|
3937
|
+
if (existing !== null)
|
|
3938
|
+
return; // Schedule already exists
|
|
3939
|
+
await runScheduleAdd("daily", { json: false, quiet: true });
|
|
3940
|
+
emit.detail("Auto-scheduled daily collection.");
|
|
3941
|
+
}
|
|
3942
|
+
function generateLaunchdPlist(vanaBinary, intervalSeconds) {
|
|
3943
|
+
const logsPath = path.join(getLogsDir(), "schedule.log");
|
|
3944
|
+
// Handle the case where vanaBinary might contain a space (node + script)
|
|
3945
|
+
const programArgs = vanaBinary.includes(" ")
|
|
3946
|
+
? vanaBinary
|
|
3947
|
+
.split(" ")
|
|
3948
|
+
.map((arg) => ` <string>${arg}</string>`)
|
|
3949
|
+
.join("\n")
|
|
3950
|
+
: ` <string>${vanaBinary}</string>`;
|
|
3951
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3952
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3953
|
+
<plist version="1.0">
|
|
3954
|
+
<dict>
|
|
3955
|
+
<key>Label</key>
|
|
3956
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
3957
|
+
<key>ProgramArguments</key>
|
|
3958
|
+
<array>
|
|
3959
|
+
${programArgs}
|
|
3960
|
+
<string>collect</string>
|
|
3961
|
+
<string>--all</string>
|
|
3962
|
+
<string>--quiet</string>
|
|
3963
|
+
<string>--no-input</string>
|
|
3964
|
+
</array>
|
|
3965
|
+
<key>StartInterval</key>
|
|
3966
|
+
<integer>${intervalSeconds}</integer>
|
|
3967
|
+
<key>StandardOutPath</key>
|
|
3968
|
+
<string>${logsPath}</string>
|
|
3969
|
+
<key>StandardErrorPath</key>
|
|
3970
|
+
<string>${logsPath}</string>
|
|
3971
|
+
<key>RunAtLoad</key>
|
|
3972
|
+
<true/>
|
|
3973
|
+
</dict>
|
|
3974
|
+
</plist>
|
|
3975
|
+
`;
|
|
3976
|
+
}
|
|
3977
|
+
function generateCrontabEntry(vanaBinary, intervalHours) {
|
|
3978
|
+
const logsPath = path.join(getLogsDir(), "schedule.log");
|
|
3979
|
+
const hourExpr = intervalHours >= 24 ? "0" : `0`;
|
|
3980
|
+
const dayExpr = "*";
|
|
3981
|
+
const hourInterval = intervalHours >= 24 ? "0" : intervalHours >= 1 ? `*/${intervalHours}` : "*";
|
|
3982
|
+
return `${hourExpr} ${hourInterval} ${dayExpr} * * ${vanaBinary} collect --all --quiet --no-input >> ${logsPath} 2>&1 ${CRONTAB_MARKER}`;
|
|
3983
|
+
}
|
|
3984
|
+
async function runScheduleAdd(interval, options) {
|
|
3985
|
+
const emit = createEmitter(options);
|
|
3986
|
+
const intervalSeconds = parseIntervalSeconds(interval);
|
|
3987
|
+
const intervalLabel = formatIntervalHuman(intervalSeconds);
|
|
3988
|
+
const vanaBinary = resolveVanaBinaryPath();
|
|
3989
|
+
await fsp.mkdir(getLogsDir(), { recursive: true });
|
|
3990
|
+
if (process.platform === "darwin") {
|
|
3991
|
+
// macOS: launchd
|
|
3992
|
+
const plist = generateLaunchdPlist(vanaBinary, intervalSeconds);
|
|
3993
|
+
const plistDir = path.dirname(LAUNCHD_PLIST_PATH);
|
|
3994
|
+
await fsp.mkdir(plistDir, { recursive: true });
|
|
3995
|
+
// Unload existing if present
|
|
3996
|
+
try {
|
|
3997
|
+
execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, {
|
|
3998
|
+
stdio: "ignore",
|
|
3999
|
+
});
|
|
4000
|
+
}
|
|
4001
|
+
catch {
|
|
4002
|
+
// Not loaded, that's fine
|
|
4003
|
+
}
|
|
4004
|
+
await fsp.writeFile(LAUNCHD_PLIST_PATH, plist);
|
|
4005
|
+
try {
|
|
4006
|
+
execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: "ignore" });
|
|
4007
|
+
}
|
|
4008
|
+
catch {
|
|
4009
|
+
emit.info("Could not load the launchd plist. Load it manually:");
|
|
4010
|
+
emit.detail(`launchctl load "${LAUNCHD_PLIST_PATH}"`);
|
|
4011
|
+
return 1;
|
|
4012
|
+
}
|
|
4013
|
+
trackActiveTelemetryEvent("schedule_added", {
|
|
4014
|
+
metadata: { interval: intervalLabel, mechanism: "launchd" },
|
|
4015
|
+
});
|
|
4016
|
+
if (options.json) {
|
|
4017
|
+
process.stdout.write(`${JSON.stringify({ ok: true, interval: intervalLabel, mechanism: "launchd", plistPath: LAUNCHD_PLIST_PATH })}\n`);
|
|
4018
|
+
return 0;
|
|
4019
|
+
}
|
|
4020
|
+
emit.info(`Added ${intervalLabel === "1d" ? "daily" : `every ${intervalLabel}`} collection schedule.`);
|
|
4021
|
+
emit.detail(`Runs: ${emit.code("vana collect --all --quiet")}`);
|
|
4022
|
+
emit.detail(`Managed by: launchd`);
|
|
4023
|
+
return 0;
|
|
4024
|
+
}
|
|
4025
|
+
if (process.platform === "linux") {
|
|
4026
|
+
// Linux: cron doesn't defer missed jobs (unlike launchd), so we run
|
|
4027
|
+
// hourly and let isCollectionDue() filter per-source. This way a
|
|
4028
|
+
// missed 2am tick self-heals at 3am instead of waiting 24h.
|
|
4029
|
+
const entry = generateCrontabEntry(vanaBinary, 1);
|
|
4030
|
+
try {
|
|
4031
|
+
// Read existing crontab, filter out old vana entries, add new one
|
|
4032
|
+
let existing = "";
|
|
4033
|
+
try {
|
|
4034
|
+
existing = execSync("crontab -l 2>/dev/null", {
|
|
4035
|
+
encoding: "utf8",
|
|
4036
|
+
});
|
|
4037
|
+
}
|
|
4038
|
+
catch {
|
|
4039
|
+
// No existing crontab
|
|
4040
|
+
}
|
|
4041
|
+
const filtered = existing
|
|
4042
|
+
.split("\n")
|
|
4043
|
+
.filter((line) => !line.includes(CRONTAB_MARKER))
|
|
4044
|
+
.join("\n");
|
|
4045
|
+
const newCrontab = `${filtered.trimEnd()}\n${entry}\n`;
|
|
4046
|
+
execSync("crontab -", {
|
|
4047
|
+
input: newCrontab,
|
|
4048
|
+
encoding: "utf8",
|
|
4049
|
+
});
|
|
4050
|
+
}
|
|
4051
|
+
catch {
|
|
4052
|
+
emit.info("Could not update crontab. Add this entry manually:");
|
|
4053
|
+
emit.detail(entry);
|
|
4054
|
+
return 1;
|
|
4055
|
+
}
|
|
4056
|
+
trackActiveTelemetryEvent("schedule_added", {
|
|
4057
|
+
metadata: { interval: intervalLabel, mechanism: "cron" },
|
|
4058
|
+
});
|
|
4059
|
+
if (options.json) {
|
|
4060
|
+
process.stdout.write(`${JSON.stringify({ ok: true, interval: intervalLabel, mechanism: "cron" })}\n`);
|
|
4061
|
+
return 0;
|
|
4062
|
+
}
|
|
4063
|
+
emit.info(`Added ${intervalLabel === "1d" ? "daily" : `every ${intervalLabel}`} collection schedule.`);
|
|
4064
|
+
emit.detail(`Runs: ${emit.code("vana collect --all --quiet")}`);
|
|
4065
|
+
emit.detail(`Managed by: cron`);
|
|
4066
|
+
return 0;
|
|
4067
|
+
}
|
|
4068
|
+
if (process.platform === "win32") {
|
|
4069
|
+
// Windows: Task Scheduler
|
|
4070
|
+
const intervalMinutes = Math.max(1, Math.round(intervalSeconds / 60));
|
|
4071
|
+
const trCmd = `\\"${vanaBinary}\\" collect --all --quiet --no-input`;
|
|
4072
|
+
try {
|
|
4073
|
+
try {
|
|
4074
|
+
execSync(`schtasks /Delete /TN "${WINDOWS_TASK_NAME}" /F 2>nul`, {
|
|
4075
|
+
stdio: "ignore",
|
|
4076
|
+
});
|
|
4077
|
+
}
|
|
4078
|
+
catch {
|
|
4079
|
+
// Not present, that's fine
|
|
4080
|
+
}
|
|
4081
|
+
if (intervalSeconds >= 86400) {
|
|
4082
|
+
execSync(`schtasks /Create /TN "${WINDOWS_TASK_NAME}" /TR "${trCmd}" /SC DAILY /ST 09:00 /F`, { stdio: "ignore" });
|
|
4083
|
+
}
|
|
4084
|
+
else {
|
|
4085
|
+
execSync(`schtasks /Create /TN "${WINDOWS_TASK_NAME}" /TR "${trCmd}" /SC MINUTE /MO ${intervalMinutes} /F`, { stdio: "ignore" });
|
|
4086
|
+
}
|
|
4087
|
+
// Enable StartWhenAvailable for deferred execution
|
|
4088
|
+
try {
|
|
4089
|
+
execSync(`powershell -Command "$t = Get-ScheduledTask '${WINDOWS_TASK_NAME}'; $t.Settings.StartWhenAvailable = $true; Set-ScheduledTask -InputObject $t"`, { stdio: "ignore" });
|
|
4090
|
+
}
|
|
4091
|
+
catch {
|
|
4092
|
+
// Non-fatal if PowerShell cmdlet fails
|
|
4093
|
+
}
|
|
4094
|
+
}
|
|
4095
|
+
catch {
|
|
4096
|
+
emit.info("Could not create scheduled task. Create it manually:");
|
|
4097
|
+
emit.detail(`schtasks /Create /TN "${WINDOWS_TASK_NAME}" /TR "${trCmd}" /SC DAILY /ST 09:00 /F`);
|
|
4098
|
+
return 1;
|
|
4099
|
+
}
|
|
4100
|
+
trackActiveTelemetryEvent("schedule_added", {
|
|
4101
|
+
metadata: { interval: intervalLabel, mechanism: "schtasks" },
|
|
4102
|
+
});
|
|
4103
|
+
if (options.json) {
|
|
4104
|
+
process.stdout.write(`${JSON.stringify({ ok: true, interval: intervalLabel, mechanism: "schtasks" })}\n`);
|
|
4105
|
+
return 0;
|
|
4106
|
+
}
|
|
4107
|
+
emit.info(`Added ${intervalLabel === "1d" ? "daily" : `every ${intervalLabel}`} collection schedule.`);
|
|
4108
|
+
emit.detail(`Runs: ${emit.code("vana collect --all --quiet")}`);
|
|
4109
|
+
emit.detail(`Managed by: Task Scheduler`);
|
|
4110
|
+
return 0;
|
|
4111
|
+
}
|
|
4112
|
+
// Unsupported platform
|
|
4113
|
+
emit.info("Scheduled collection is not supported on this platform. Run `vana collect --all` manually.");
|
|
4114
|
+
return 1;
|
|
4115
|
+
}
|
|
4116
|
+
async function runScheduleList(options) {
|
|
4117
|
+
const emit = createEmitter(options);
|
|
4118
|
+
if (process.platform === "darwin") {
|
|
4119
|
+
// Check launchd plist
|
|
4120
|
+
try {
|
|
4121
|
+
await fsp.access(LAUNCHD_PLIST_PATH);
|
|
4122
|
+
const content = await fsp.readFile(LAUNCHD_PLIST_PATH, "utf8");
|
|
4123
|
+
const intervalMatch = content.match(/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/);
|
|
4124
|
+
const intervalSeconds = intervalMatch
|
|
4125
|
+
? parseInt(intervalMatch[1], 10)
|
|
4126
|
+
: 86400;
|
|
4127
|
+
const intervalLabel = formatIntervalHuman(intervalSeconds);
|
|
4128
|
+
const nextInSeconds = intervalSeconds; // Approximate; launchd doesn't expose exact next-run
|
|
4129
|
+
const nextLabel = `~${formatIntervalHuman(nextInSeconds)}`;
|
|
4130
|
+
if (options.json) {
|
|
4131
|
+
process.stdout.write(`${JSON.stringify({
|
|
4132
|
+
scheduled: true,
|
|
4133
|
+
interval: intervalLabel,
|
|
4134
|
+
intervalSeconds,
|
|
4135
|
+
mechanism: "launchd",
|
|
4136
|
+
plistPath: LAUNCHD_PLIST_PATH,
|
|
4137
|
+
})}\n`);
|
|
4138
|
+
return 0;
|
|
4139
|
+
}
|
|
4140
|
+
emit.keyValue("Daily collection", `every ${intervalLabel} next: ${nextLabel}`, "muted");
|
|
4141
|
+
emit.detail(`Managed by: ${LAUNCHD_PLIST_PATH}`);
|
|
4142
|
+
return 0;
|
|
4143
|
+
}
|
|
4144
|
+
catch {
|
|
4145
|
+
// No plist found
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
if (process.platform === "linux") {
|
|
4149
|
+
// Check crontab
|
|
4150
|
+
try {
|
|
4151
|
+
const crontab = execSync("crontab -l 2>/dev/null", {
|
|
4152
|
+
encoding: "utf8",
|
|
4153
|
+
});
|
|
4154
|
+
const vanaLine = crontab
|
|
4155
|
+
.split("\n")
|
|
4156
|
+
.find((line) => line.includes(CRONTAB_MARKER));
|
|
4157
|
+
if (vanaLine) {
|
|
4158
|
+
if (options.json) {
|
|
4159
|
+
process.stdout.write(`${JSON.stringify({
|
|
4160
|
+
scheduled: true,
|
|
4161
|
+
mechanism: "cron",
|
|
4162
|
+
entry: vanaLine,
|
|
4163
|
+
})}\n`);
|
|
4164
|
+
return 0;
|
|
4165
|
+
}
|
|
4166
|
+
emit.keyValue("Daily collection", "cron", "muted");
|
|
4167
|
+
emit.detail(`Entry: ${vanaLine.replace(CRONTAB_MARKER, "").trim()}`);
|
|
4168
|
+
return 0;
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
catch {
|
|
4172
|
+
// No crontab available
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
if (process.platform === "win32") {
|
|
4176
|
+
try {
|
|
4177
|
+
const output = execSync(`schtasks /Query /TN "${WINDOWS_TASK_NAME}" /FO LIST`, { encoding: "utf8" });
|
|
4178
|
+
if (options.json) {
|
|
4179
|
+
process.stdout.write(`${JSON.stringify({
|
|
4180
|
+
scheduled: true,
|
|
4181
|
+
mechanism: "schtasks",
|
|
4182
|
+
taskName: WINDOWS_TASK_NAME,
|
|
4183
|
+
})}\n`);
|
|
4184
|
+
return 0;
|
|
4185
|
+
}
|
|
4186
|
+
emit.keyValue("Daily collection", "Task Scheduler", "muted");
|
|
4187
|
+
// Extract schedule info from output
|
|
4188
|
+
const statusLine = output.split("\n").find((l) => l.includes("Status:"));
|
|
4189
|
+
if (statusLine) {
|
|
4190
|
+
emit.detail(statusLine.trim());
|
|
4191
|
+
}
|
|
4192
|
+
return 0;
|
|
4193
|
+
}
|
|
4194
|
+
catch {
|
|
4195
|
+
// Task not found
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
4198
|
+
if (options.json) {
|
|
4199
|
+
process.stdout.write(`${JSON.stringify({ scheduled: false })}\n`);
|
|
4200
|
+
return 0;
|
|
4201
|
+
}
|
|
4202
|
+
emit.info("No scheduled collection found.");
|
|
4203
|
+
emit.detail(`Add one with ${emit.code("vana schedule add")}.`);
|
|
4204
|
+
return 0;
|
|
4205
|
+
}
|
|
4206
|
+
async function runScheduleRemove(options) {
|
|
4207
|
+
const emit = createEmitter(options);
|
|
4208
|
+
if (process.platform === "darwin") {
|
|
4209
|
+
try {
|
|
4210
|
+
await fsp.access(LAUNCHD_PLIST_PATH);
|
|
4211
|
+
try {
|
|
4212
|
+
execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}"`, {
|
|
4213
|
+
stdio: "ignore",
|
|
4214
|
+
});
|
|
4215
|
+
}
|
|
4216
|
+
catch {
|
|
4217
|
+
// Already unloaded
|
|
4218
|
+
}
|
|
4219
|
+
await fsp.unlink(LAUNCHD_PLIST_PATH);
|
|
4220
|
+
trackActiveTelemetryEvent("schedule_removed", {
|
|
4221
|
+
metadata: { mechanism: "launchd" },
|
|
4222
|
+
});
|
|
4223
|
+
if (options.json) {
|
|
4224
|
+
process.stdout.write(`${JSON.stringify({ ok: true, removed: true })}\n`);
|
|
4225
|
+
return 0;
|
|
4226
|
+
}
|
|
4227
|
+
emit.info("Removed daily collection schedule.");
|
|
4228
|
+
return 0;
|
|
4229
|
+
}
|
|
4230
|
+
catch {
|
|
4231
|
+
// No plist found, fall through
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
if (process.platform === "linux") {
|
|
4235
|
+
try {
|
|
4236
|
+
const existing = execSync("crontab -l 2>/dev/null", {
|
|
4237
|
+
encoding: "utf8",
|
|
4238
|
+
});
|
|
4239
|
+
if (existing.includes(CRONTAB_MARKER)) {
|
|
4240
|
+
const filtered = existing
|
|
4241
|
+
.split("\n")
|
|
4242
|
+
.filter((line) => !line.includes(CRONTAB_MARKER))
|
|
4243
|
+
.join("\n");
|
|
4244
|
+
execSync("crontab -", {
|
|
4245
|
+
input: `${filtered.trimEnd()}\n`,
|
|
4246
|
+
encoding: "utf8",
|
|
4247
|
+
});
|
|
4248
|
+
trackActiveTelemetryEvent("schedule_removed", {
|
|
4249
|
+
metadata: { mechanism: "cron" },
|
|
4250
|
+
});
|
|
4251
|
+
if (options.json) {
|
|
4252
|
+
process.stdout.write(`${JSON.stringify({ ok: true, removed: true })}\n`);
|
|
4253
|
+
return 0;
|
|
4254
|
+
}
|
|
4255
|
+
emit.info("Removed daily collection schedule.");
|
|
4256
|
+
return 0;
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
catch {
|
|
4260
|
+
// No crontab available
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
if (process.platform === "win32") {
|
|
4264
|
+
try {
|
|
4265
|
+
execSync(`schtasks /Delete /TN "${WINDOWS_TASK_NAME}" /F`, {
|
|
4266
|
+
stdio: "ignore",
|
|
4267
|
+
});
|
|
4268
|
+
trackActiveTelemetryEvent("schedule_removed", {
|
|
4269
|
+
metadata: { mechanism: "schtasks" },
|
|
4270
|
+
});
|
|
4271
|
+
if (options.json) {
|
|
4272
|
+
process.stdout.write(`${JSON.stringify({ ok: true, removed: true })}\n`);
|
|
4273
|
+
return 0;
|
|
4274
|
+
}
|
|
4275
|
+
emit.info("Removed daily collection schedule.");
|
|
4276
|
+
return 0;
|
|
4277
|
+
}
|
|
4278
|
+
catch {
|
|
4279
|
+
// Task not found
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4282
|
+
if (options.json) {
|
|
4283
|
+
process.stdout.write(`${JSON.stringify({ ok: true, removed: false })}\n`);
|
|
4284
|
+
return 0;
|
|
4285
|
+
}
|
|
4286
|
+
emit.info("No scheduled collection found to remove.");
|
|
4287
|
+
return 0;
|
|
4288
|
+
}
|
|
4289
|
+
function isPromptCancelled(error) {
|
|
4290
|
+
return (error instanceof Error &&
|
|
4291
|
+
(error.name === "ExitPromptError" || error.message.includes("SIGINT")));
|
|
4292
|
+
}
|
|
4293
|
+
// ---------------------------------------------------------------------------
|
|
4294
|
+
// Skill commands
|
|
4295
|
+
// ---------------------------------------------------------------------------
|
|
4296
|
+
const BASE_SKILL_ID = "connect-data";
|
|
4297
|
+
async function maybePromptSkillInstall(emit) {
|
|
4298
|
+
try {
|
|
4299
|
+
const skills = await listAvailableSkills();
|
|
4300
|
+
const baseSkill = skills.find((s) => s.id === BASE_SKILL_ID);
|
|
4301
|
+
if (!baseSkill)
|
|
4302
|
+
return;
|
|
4303
|
+
const installed = await readInstalledSkills();
|
|
4304
|
+
if (installed.some((s) => s.id === BASE_SKILL_ID)) {
|
|
4305
|
+
await updateCliConfig({ skillsPromptCompleted: true });
|
|
4306
|
+
return;
|
|
4307
|
+
}
|
|
4308
|
+
emit.blank();
|
|
4309
|
+
const shouldInstall = await confirm({
|
|
4310
|
+
message: "Install a skill so your coding agent knows how to use your connected data?",
|
|
4311
|
+
default: true,
|
|
4312
|
+
...vanaPromptTheme,
|
|
4313
|
+
});
|
|
4314
|
+
if (shouldInstall) {
|
|
4315
|
+
try {
|
|
4316
|
+
await installSkill(BASE_SKILL_ID);
|
|
4317
|
+
emit.success(`Installed skill: ${baseSkill.name}`);
|
|
4318
|
+
}
|
|
4319
|
+
catch {
|
|
4320
|
+
// Non-fatal.
|
|
4321
|
+
}
|
|
4322
|
+
const remaining = skills.filter((s) => s.id !== BASE_SKILL_ID);
|
|
4323
|
+
if (remaining.length > 0) {
|
|
4324
|
+
emit.next("vana skills");
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
await updateCliConfig({ skillsPromptCompleted: true });
|
|
4328
|
+
}
|
|
4329
|
+
catch {
|
|
4330
|
+
// Prompt cancelled or error — mark as completed to avoid re-asking.
|
|
4331
|
+
await updateCliConfig({ skillsPromptCompleted: true });
|
|
4332
|
+
}
|
|
4333
|
+
}
|
|
4334
|
+
async function runSkillsGuidedPicker(options) {
|
|
4335
|
+
const emit = createEmitter(options);
|
|
4336
|
+
if (options.json) {
|
|
4337
|
+
// In JSON mode, fall back to list behavior
|
|
4338
|
+
return runSkillList(options);
|
|
4339
|
+
}
|
|
4340
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
4341
|
+
return runSkillList(options);
|
|
4342
|
+
}
|
|
4343
|
+
try {
|
|
4344
|
+
const skills = await listAvailableSkills();
|
|
4345
|
+
const installed = await readInstalledSkills();
|
|
4346
|
+
const installedIds = new Set(installed.map((s) => s.id));
|
|
4347
|
+
if (skills.length === 0) {
|
|
4348
|
+
emit.info("No skills are available right now.");
|
|
4349
|
+
return 0;
|
|
4350
|
+
}
|
|
4351
|
+
const choices = skills.map((skill) => {
|
|
4352
|
+
const isInstalled = installedIds.has(skill.id);
|
|
4353
|
+
return {
|
|
4354
|
+
value: skill.id,
|
|
4355
|
+
name: `${skill.name}${isInstalled ? " (installed)" : ""}`,
|
|
4356
|
+
description: skill.description,
|
|
4357
|
+
};
|
|
4358
|
+
});
|
|
4359
|
+
const selectedId = await searchSelect({
|
|
4360
|
+
message: "Select a skill.",
|
|
4361
|
+
choices,
|
|
4362
|
+
...vanaPromptTheme,
|
|
4363
|
+
});
|
|
4364
|
+
if (installedIds.has(selectedId)) {
|
|
4365
|
+
return runSkillShow(selectedId, options);
|
|
4366
|
+
}
|
|
4367
|
+
return runSkillInstall(selectedId, options);
|
|
4368
|
+
}
|
|
4369
|
+
catch (error) {
|
|
4370
|
+
if (isPromptCancelled(error)) {
|
|
4371
|
+
emit.info("Cancelled.");
|
|
4372
|
+
return 1;
|
|
4373
|
+
}
|
|
4374
|
+
throw error;
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
4377
|
+
async function runSkillList(options) {
|
|
4378
|
+
const emit = createEmitter(options);
|
|
4379
|
+
try {
|
|
4380
|
+
const skills = await listAvailableSkills();
|
|
4381
|
+
const installed = await readInstalledSkills();
|
|
4382
|
+
const installedIds = new Set(installed.map((s) => s.id));
|
|
4383
|
+
const enriched = skills.map((skill) => ({
|
|
4384
|
+
...skill,
|
|
4385
|
+
installed: installedIds.has(skill.id),
|
|
4386
|
+
}));
|
|
4387
|
+
if (options.json) {
|
|
4388
|
+
process.stdout.write(`${JSON.stringify({ count: enriched.length, skills: enriched })}\n`);
|
|
4389
|
+
return 0;
|
|
4390
|
+
}
|
|
4391
|
+
emit.title("Available skills");
|
|
4392
|
+
emit.blank();
|
|
4393
|
+
if (enriched.length === 0) {
|
|
4394
|
+
emit.info("No skills are available right now.");
|
|
4395
|
+
return 0;
|
|
4396
|
+
}
|
|
4397
|
+
for (const skill of enriched) {
|
|
4398
|
+
const tag = skill.installed
|
|
4399
|
+
? ` ${emit.badge("installed", "accent")}`
|
|
4400
|
+
: "";
|
|
4401
|
+
emit.info(` ${skill.name}${tag}`);
|
|
4402
|
+
emit.detail(` ${skill.description}`);
|
|
4403
|
+
}
|
|
4404
|
+
const uninstalled = enriched.find((s) => !s.installed);
|
|
4405
|
+
if (uninstalled) {
|
|
4406
|
+
emit.blank();
|
|
4407
|
+
emit.next(`vana skills install ${uninstalled.id}`);
|
|
4408
|
+
}
|
|
4409
|
+
return 0;
|
|
4410
|
+
}
|
|
4411
|
+
catch (error) {
|
|
4412
|
+
if (options.json) {
|
|
4413
|
+
process.stdout.write(`${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
4414
|
+
}
|
|
4415
|
+
else {
|
|
4416
|
+
emit.info(error instanceof Error ? error.message : String(error));
|
|
4417
|
+
}
|
|
4418
|
+
return 1;
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
async function runSkillInstall(name, options) {
|
|
4422
|
+
const emit = createEmitter(options);
|
|
4423
|
+
try {
|
|
4424
|
+
const { installedPath } = await installSkill(name);
|
|
4425
|
+
trackActiveTelemetryEvent("skill_installed", {
|
|
4426
|
+
metadata: { skillName: name },
|
|
4427
|
+
});
|
|
4428
|
+
if (options.json) {
|
|
4429
|
+
process.stdout.write(`${JSON.stringify({ ok: true, id: name, installedPath })}\n`);
|
|
4430
|
+
return 0;
|
|
4431
|
+
}
|
|
4432
|
+
emit.success(`Installed ${name}.`);
|
|
4433
|
+
emit.blank();
|
|
4434
|
+
const skills = await listAvailableSkills();
|
|
4435
|
+
const installed = await readInstalledSkills();
|
|
4436
|
+
const installedIds = new Set([...installed.map((s) => s.id), name]);
|
|
4437
|
+
const nextSkill = skills.find((s) => !installedIds.has(s.id));
|
|
4438
|
+
emit.next(nextSkill ? `vana skills install ${nextSkill.id}` : "vana skills list");
|
|
4439
|
+
return 0;
|
|
4440
|
+
}
|
|
4441
|
+
catch (error) {
|
|
4442
|
+
if (options.json) {
|
|
4443
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
4444
|
+
}
|
|
4445
|
+
else {
|
|
4446
|
+
emit.info(error instanceof Error ? error.message : String(error));
|
|
4447
|
+
}
|
|
4448
|
+
return 1;
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4451
|
+
async function runSkillShow(name, options) {
|
|
4452
|
+
const emit = createEmitter(options);
|
|
4453
|
+
try {
|
|
4454
|
+
const skills = await listAvailableSkills();
|
|
4455
|
+
const match = skills.find((s) => s.id.toLowerCase() === name.toLowerCase());
|
|
4456
|
+
if (!match) {
|
|
4457
|
+
if (options.json) {
|
|
4458
|
+
process.stdout.write(`${JSON.stringify({ error: `No skill found with id "${name}".` })}\n`);
|
|
4459
|
+
}
|
|
4460
|
+
else {
|
|
4461
|
+
emit.info(`No skill found with id "${name}".`);
|
|
4462
|
+
emit.blank();
|
|
4463
|
+
emit.next("vana skills list");
|
|
4464
|
+
}
|
|
4465
|
+
return 1;
|
|
4466
|
+
}
|
|
4467
|
+
const installed = await readInstalledSkills();
|
|
4468
|
+
const isInstalled = installed.some((s) => s.id === match.id);
|
|
4469
|
+
if (options.json) {
|
|
4470
|
+
process.stdout.write(`${JSON.stringify({ ...match, installed: isInstalled })}\n`);
|
|
4471
|
+
return 0;
|
|
4472
|
+
}
|
|
4473
|
+
const badges = [];
|
|
4474
|
+
if (isInstalled) {
|
|
4475
|
+
badges.push({ text: "installed", tone: "success" });
|
|
4476
|
+
}
|
|
4477
|
+
emit.sourceTitle(match.name, badges);
|
|
4478
|
+
emit.detail(match.description);
|
|
4479
|
+
emit.keyValue("Version", match.version);
|
|
4480
|
+
if (!isInstalled) {
|
|
4481
|
+
emit.blank();
|
|
4482
|
+
emit.next(`vana skills install ${match.id}`);
|
|
4483
|
+
}
|
|
4484
|
+
return 0;
|
|
4485
|
+
}
|
|
4486
|
+
catch (error) {
|
|
4487
|
+
if (options.json) {
|
|
4488
|
+
process.stdout.write(`${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
4489
|
+
}
|
|
4490
|
+
else {
|
|
4491
|
+
emit.info(error instanceof Error ? error.message : String(error));
|
|
4492
|
+
}
|
|
4493
|
+
return 1;
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
// ── Login / Logout ─────────────────────────────────────────────────────
|
|
4497
|
+
async function runLogin(options, serverUrl, clientId) {
|
|
4498
|
+
// Determine auth target: cloud (account.vana.org) or self-hosted (PS directly)
|
|
4499
|
+
const psUrl = serverUrl ?? resolvePersonalServerUrl() ?? null;
|
|
4500
|
+
const authTarget = getAuthTarget(psUrl);
|
|
4501
|
+
// If self-hosted, use /auth/device flow against the PS
|
|
4502
|
+
if (authTarget === "self-hosted" && psUrl) {
|
|
4503
|
+
const renderer = !options.json && !options.quiet ? createLoginRenderer() : null;
|
|
4504
|
+
const humanRenderer = createHumanRenderer();
|
|
4505
|
+
renderer?.title(psUrl);
|
|
4506
|
+
try {
|
|
4507
|
+
const result = await runSelfHostedLoginFlow(psUrl, (url) => {
|
|
4508
|
+
if (!options.json && !options.quiet) {
|
|
4509
|
+
process.stderr.write(` ${humanRenderer.theme.muted("Open this URL in your browser:")}\n`);
|
|
4510
|
+
process.stderr.write(` ${humanRenderer.theme.muted(url)}\n`);
|
|
4511
|
+
}
|
|
4512
|
+
renderer?.scopeActive("Waiting for authorization");
|
|
4513
|
+
// Try to open browser — use spawn with args array to prevent shell injection
|
|
4514
|
+
// (a malicious self-hosted PS could return a URL with shell metacharacters)
|
|
4515
|
+
try {
|
|
4516
|
+
const { spawn } = require("node:child_process");
|
|
4517
|
+
const opener = process.platform === "darwin"
|
|
4518
|
+
? "open"
|
|
4519
|
+
: process.platform === "win32"
|
|
4520
|
+
? "start"
|
|
4521
|
+
: "xdg-open";
|
|
4522
|
+
spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
|
|
4523
|
+
}
|
|
4524
|
+
catch {
|
|
4525
|
+
// Browser open failed — user will open manually
|
|
4526
|
+
}
|
|
4527
|
+
});
|
|
4528
|
+
await saveCredentials({
|
|
4529
|
+
account: {
|
|
4530
|
+
address: result.address,
|
|
4531
|
+
session_token: "",
|
|
4532
|
+
expires_at: result.expires_at,
|
|
4533
|
+
},
|
|
4534
|
+
personal_server: {
|
|
4535
|
+
url: psUrl,
|
|
4536
|
+
session_token: result.session_token,
|
|
4537
|
+
expires_at: result.expires_at,
|
|
4538
|
+
},
|
|
4539
|
+
});
|
|
4540
|
+
await updateCliConfig({ personalServerUrl: psUrl });
|
|
4541
|
+
renderer?.success(`Logged in to ${psUrl}`);
|
|
4542
|
+
renderer?.detail("Credentials saved to ~/.vana/auth.json");
|
|
4543
|
+
return 0;
|
|
4544
|
+
}
|
|
4545
|
+
catch (err) {
|
|
4546
|
+
renderer?.fail("Login failed");
|
|
4547
|
+
renderer?.detail(err instanceof Error ? err.message : String(err));
|
|
4548
|
+
renderer?.next(`vana login --server ${psUrl}`);
|
|
4549
|
+
return 1;
|
|
4550
|
+
}
|
|
4551
|
+
finally {
|
|
4552
|
+
renderer?.cleanup();
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
// Cloud flow (account.vana.org)
|
|
4556
|
+
// Check env var shortcut
|
|
4557
|
+
const envToken = process.env.VANA_SESSION_TOKEN;
|
|
4558
|
+
if (envToken) {
|
|
4559
|
+
const creds = loadCredentials();
|
|
4560
|
+
if (creds) {
|
|
4561
|
+
if (options.json) {
|
|
4562
|
+
process.stdout.write(`${JSON.stringify({ status: "authenticated", source: "env", address: creds.account.address })}\n`);
|
|
4563
|
+
}
|
|
4564
|
+
else {
|
|
4565
|
+
const emit = createEmitter(options);
|
|
4566
|
+
emit.success(`Already authenticated via VANA_SESSION_TOKEN env var`);
|
|
4567
|
+
}
|
|
4568
|
+
return 0;
|
|
4569
|
+
}
|
|
4570
|
+
}
|
|
4571
|
+
// Check if already logged in
|
|
4572
|
+
const existing = loadCredentials();
|
|
4573
|
+
if (existing && !isExpired(existing)) {
|
|
4574
|
+
if (options.json) {
|
|
4575
|
+
process.stdout.write(`${JSON.stringify({
|
|
4576
|
+
status: "authenticated",
|
|
4577
|
+
address: existing.account.address,
|
|
4578
|
+
personal_server: existing.personal_server?.url ?? null,
|
|
4579
|
+
expires_at: existing.account.expires_at,
|
|
4580
|
+
})}\n`);
|
|
4581
|
+
}
|
|
4582
|
+
else {
|
|
4583
|
+
const emit = createEmitter(options);
|
|
4584
|
+
emit.success(`Already logged in as ${formatAddress(existing.account.address)}`);
|
|
4585
|
+
if (existing.personal_server) {
|
|
4586
|
+
emit.keyValue("Personal Server", existing.personal_server.url, "success");
|
|
4587
|
+
}
|
|
4588
|
+
emit.info(` Auth expires in ${formatExpiresIn(existing.account.expires_at)}`);
|
|
4589
|
+
emit.blank();
|
|
4590
|
+
emit.info(" Run `vana logout` first to re-authenticate.");
|
|
4591
|
+
}
|
|
4592
|
+
return 0;
|
|
4593
|
+
}
|
|
4594
|
+
if (options.json) {
|
|
4595
|
+
// JSON mode: run flow and output result
|
|
4596
|
+
const creds = await runDeviceCodeFlow({
|
|
4597
|
+
onCode: (code, uri) => {
|
|
4598
|
+
process.stderr.write(JSON.stringify({ event: "device_code", code, uri }) + "\n");
|
|
4599
|
+
},
|
|
4600
|
+
onWaiting: () => { },
|
|
4601
|
+
onAuthorized: () => { },
|
|
4602
|
+
onExpired: () => {
|
|
4603
|
+
process.stdout.write(JSON.stringify({
|
|
4604
|
+
status: "expired",
|
|
4605
|
+
error: "Device code expired",
|
|
4606
|
+
}) + "\n");
|
|
4607
|
+
},
|
|
4608
|
+
onError: (err) => {
|
|
4609
|
+
process.stdout.write(JSON.stringify({ status: "error", error: err.message }) + "\n");
|
|
4610
|
+
},
|
|
4611
|
+
}, { clientId });
|
|
4612
|
+
if (creds) {
|
|
4613
|
+
await saveCredentials(creds);
|
|
4614
|
+
if (creds.personal_server?.url) {
|
|
4615
|
+
await updateCliConfig({ personalServerUrl: creds.personal_server.url });
|
|
4616
|
+
}
|
|
4617
|
+
process.stdout.write(`${JSON.stringify({
|
|
4618
|
+
status: "authenticated",
|
|
4619
|
+
address: creds.account.address,
|
|
4620
|
+
personal_server: creds.personal_server?.url ?? null,
|
|
4621
|
+
expires_at: creds.account.expires_at,
|
|
4622
|
+
})}\n`);
|
|
4623
|
+
return 0;
|
|
4624
|
+
}
|
|
4625
|
+
return 1;
|
|
4626
|
+
}
|
|
4627
|
+
// Interactive mode
|
|
4628
|
+
const renderer = createLoginRenderer();
|
|
4629
|
+
const humanRenderer = createHumanRenderer();
|
|
4630
|
+
const writeStaticLoginNotes = (lines) => {
|
|
4631
|
+
if (options.json || options.quiet) {
|
|
4632
|
+
return;
|
|
4633
|
+
}
|
|
4634
|
+
for (const line of lines) {
|
|
4635
|
+
process.stderr.write(` ${humanRenderer.theme.muted(line)}\n`);
|
|
4636
|
+
}
|
|
4637
|
+
};
|
|
4638
|
+
renderer.title("Vana");
|
|
4639
|
+
const creds = await runDeviceCodeFlow({
|
|
4640
|
+
onCode: (code, uri) => {
|
|
4641
|
+
writeStaticLoginNotes([
|
|
4642
|
+
"Open this URL in your browser:",
|
|
4643
|
+
uri,
|
|
4644
|
+
`Enter this code: ${code}`,
|
|
4645
|
+
]);
|
|
4646
|
+
},
|
|
4647
|
+
onWaiting: () => {
|
|
4648
|
+
renderer.scopeActive("Waiting for authorization");
|
|
4649
|
+
},
|
|
4650
|
+
onAuthorized: async (authedCreds) => {
|
|
4651
|
+
await saveCredentials(authedCreds);
|
|
4652
|
+
if (authedCreds.personal_server?.url) {
|
|
4653
|
+
await updateCliConfig({
|
|
4654
|
+
personalServerUrl: authedCreds.personal_server.url,
|
|
4655
|
+
});
|
|
4656
|
+
}
|
|
4657
|
+
renderer.success(`Logged in as ${formatAddress(authedCreds.account.address)}`);
|
|
4658
|
+
if (authedCreds.personal_server) {
|
|
4659
|
+
renderer.detail(`Personal Server: ${authedCreds.personal_server.url}`);
|
|
4660
|
+
}
|
|
4661
|
+
renderer.detail("Credentials saved to ~/.vana/auth.json");
|
|
4662
|
+
},
|
|
4663
|
+
onExpired: () => {
|
|
4664
|
+
renderer.fail("Authorization expired");
|
|
4665
|
+
renderer.next("vana login");
|
|
4666
|
+
},
|
|
4667
|
+
onError: (err) => {
|
|
4668
|
+
renderer.fail("Login failed");
|
|
4669
|
+
renderer.detail(err.message);
|
|
4670
|
+
renderer.next("vana login");
|
|
4671
|
+
},
|
|
4672
|
+
}, { clientId });
|
|
4673
|
+
renderer.cleanup();
|
|
4674
|
+
return creds ? 0 : 1;
|
|
4675
|
+
}
|
|
4676
|
+
async function runLogout(options) {
|
|
4677
|
+
// Revoke the token server-side before clearing local credentials
|
|
4678
|
+
const creds = loadCredentials();
|
|
4679
|
+
if (creds?.personal_server?.url && creds.personal_server.session_token) {
|
|
4680
|
+
try {
|
|
4681
|
+
await fetch(`${creds.personal_server.url.replace(/\/$/, "")}/auth/device/token`, {
|
|
4682
|
+
method: "DELETE",
|
|
4683
|
+
headers: {
|
|
4684
|
+
Authorization: `Bearer ${creds.personal_server.session_token}`,
|
|
4685
|
+
},
|
|
4686
|
+
signal: AbortSignal.timeout(5000),
|
|
4687
|
+
});
|
|
4688
|
+
}
|
|
4689
|
+
catch {
|
|
4690
|
+
// Best-effort — server may be down, but we still clear local creds
|
|
4691
|
+
}
|
|
4692
|
+
}
|
|
4693
|
+
await clearCredentials();
|
|
4694
|
+
if (options.json) {
|
|
4695
|
+
process.stdout.write(`${JSON.stringify({ status: "logged_out" })}\n`);
|
|
4696
|
+
}
|
|
4697
|
+
else {
|
|
4698
|
+
const emit = createEmitter(options);
|
|
4699
|
+
emit.success("Logged out. Credentials removed.");
|
|
4700
|
+
}
|
|
4701
|
+
return 0;
|
|
4702
|
+
}
|
|
4703
|
+
//# sourceMappingURL=index.js.map
|